/* my-deepagent Web GUI — v0.2 PR #3 (polish pass). * * SECURITY (XSS policy): * All user-controlled strings MUST be inserted via element.textContent. * element.innerHTML / insertAdjacentHTML / outerHTML are FORBIDDEN. * See plan.md D3 "GUI 정책 명시" — markdown / HTML rendering is v0.3+. */ const API = window.location.origin + "/api"; const TERMINAL_STATES = new Set(["completed", "failed", "aborted"]); function $(sel) { return document.querySelector(sel); } function $$(sel) { return Array.from(document.querySelectorAll(sel)); } async function jsonFetch(path, opts = {}) { const r = await fetch(API + path, opts); if (!r.ok) { let detail = r.statusText; try { const body = await r.json(); detail = body.detail || JSON.stringify(body); } catch (_) { /* ignore */ } throw new Error(`${r.status} ${detail}`); } return r.json(); } function setError(msg) { const el = $("#error"); if (!el) return; if (msg) { el.textContent = msg; el.style.display = "flex"; } else { el.style.display = "none"; } } function badge(state) { const span = document.createElement("span"); span.className = `badge state-${state}`; span.textContent = state; return span; } function emptyCell(colspan, text, ctaHref, ctaText) { const tr = document.createElement("tr"); const td = document.createElement("td"); td.colSpan = colspan; const empty = document.createElement("div"); empty.className = "empty"; const icon = document.createElement("div"); icon.className = "empty-icon"; icon.textContent = "◌"; const body = document.createElement("div"); body.textContent = text; empty.appendChild(icon); empty.appendChild(body); if (ctaHref && ctaText) { const cta = document.createElement("div"); cta.className = "cta"; const a = document.createElement("a"); a.className = "button"; a.href = ctaHref; a.textContent = ctaText; cta.appendChild(a); empty.appendChild(cta); } td.appendChild(empty); tr.appendChild(td); return tr; } // =============== index.html =============== async function renderRunsList() { setError(""); let runs; try { runs = await jsonFetch("/runs?limit=50"); } catch (e) { setError(`runs 목록을 불러오지 못했습니다: ${e.message}`); return; } const tbody = $("#runs tbody"); tbody.replaceChildren(); if (runs.length === 0) { tbody.appendChild( emptyCell(6, "아직 실행된 run이 없습니다.", "/new.html", "새 Run 시작 →") ); return; } for (const r of runs) { const tr = document.createElement("tr"); // Run ID cell — short hash + arrow const idTd = document.createElement("td"); const idLink = document.createElement("a"); idLink.href = `/run.html?id=${r.id}`; idLink.className = "mono"; idLink.textContent = r.id.slice(0, 8) + "…"; idTd.appendChild(idLink); // State cell — pill badge const stateTd = document.createElement("td"); stateTd.appendChild(badge(r.state)); // Repo cell — basename only, full path on title const repoTd = document.createElement("td"); const repoSpan = document.createElement("span"); repoSpan.className = "mono"; repoSpan.textContent = (r.repo_path || "").split("/").pop() || r.repo_path; repoSpan.title = r.repo_path; repoTd.appendChild(repoSpan); // Branch const branchTd = document.createElement("td"); branchTd.textContent = r.base_branch; // Timestamps const createdTd = document.createElement("td"); createdTd.className = "mono"; createdTd.textContent = (r.created_at || "").slice(0, 19).replace("T", " "); const endedTd = document.createElement("td"); endedTd.className = "mono"; endedTd.textContent = r.ended_at ? r.ended_at.slice(0, 19).replace("T", " ") : "—"; tr.append(idTd, stateTd, repoTd, branchTd, createdTd, endedTd); tbody.appendChild(tr); } } async function renderBudgetSummary() { const container = $("#budget-summary"); if (!container) return; container.replaceChildren(); try { const summary = await jsonFetch("/budget"); function card(scope, spent, cap, warn) { const c = document.createElement("div"); c.className = "budget-card"; const s = document.createElement("div"); s.className = "scope"; s.textContent = scope; s.title = scope; const a = document.createElement("div"); a.className = "amount"; const capText = cap != null ? ` / $${cap.toFixed(2)}` : ""; a.textContent = `$${spent.toFixed(4)}`; const capSpan = document.createElement("span"); capSpan.className = "cap"; capSpan.textContent = capText; a.appendChild(capSpan); // Status colour let pct = 0; let status = "ok"; if (cap != null && cap > 0) { pct = Math.min(100, (spent / cap) * 100); if (spent >= cap) { status = "over"; a.classList.add("over"); } else if (warn != null && spent >= warn) { status = "warn"; a.classList.add("warn"); } } const bar = document.createElement("div"); bar.className = `bar ${status === "ok" ? "" : status}`; const fill = document.createElement("div"); fill.style.width = `${pct}%`; bar.appendChild(fill); c.append(s, a, bar); container.appendChild(c); } if (summary.day) card(summary.day.scope, summary.day.spent_usd, summary.day.cap_usd, summary.day.warn_usd); for (const r of summary.runs) card(r.scope, r.spent_usd, r.cap_usd, r.warn_usd); for (const p of summary.personas) card(p.scope, p.spent_usd, p.cap_usd, p.warn_usd); if (!summary.day && summary.runs.length === 0 && summary.personas.length === 0) { const empty = document.createElement("div"); empty.className = "empty"; empty.textContent = "지출 기록이 없습니다."; empty.style.gridColumn = "1 / -1"; container.appendChild(empty); } } catch (e) { const err = document.createElement("div"); err.className = "empty"; err.textContent = `budget 정보를 불러오지 못했습니다: ${e.message}`; err.style.gridColumn = "1 / -1"; container.appendChild(err); } } // =============== new.html =============== async function renderNewRunForm() { setError(""); const tplSelect = $("#template"); const overrideContainer = $("#override-fields"); let workflows = []; try { workflows = await jsonFetch("/workflows"); } catch (e) { setError(`workflow 목록을 불러오지 못했습니다: ${e.message}`); return; } for (const w of workflows) { const opt = document.createElement("option"); opt.value = w.path.replace(/^.*workflows\//, ""); opt.textContent = `${w.name}@${w.version} — ${w.description || ""}`; opt.dataset.roles = JSON.stringify(w.roles.map((r) => r.id)); tplSelect.appendChild(opt); } tplSelect.addEventListener("change", () => { overrideContainer.replaceChildren(); const roles = JSON.parse(tplSelect.selectedOptions[0].dataset.roles || "[]"); if (roles.length === 0) { const empty = document.createElement("div"); empty.className = "empty"; empty.style.padding = "20px 16px"; empty.textContent = "이 워크플로우엔 역할 정의가 없습니다."; overrideContainer.appendChild(empty); return; } for (const role of roles) { const row = document.createElement("div"); row.className = "chips"; const label = document.createElement("div"); label.className = "role"; const roleName = document.createElement("span"); roleName.textContent = role; label.appendChild(roleName); const hint = document.createElement("span"); hint.className = "hint"; hint.textContent = "비우면 자동 선택"; label.appendChild(hint); const input = document.createElement("input"); input.type = "text"; input.dataset.role = role; input.className = "override-input"; input.placeholder = "openrouter-deepseek-spec-writer@1"; row.append(label, input); overrideContainer.appendChild(row); } }); if (tplSelect.options.length > 0) { tplSelect.value = tplSelect.options[0].value; tplSelect.dispatchEvent(new Event("change")); } $("#start-form").addEventListener("submit", async (ev) => { ev.preventDefault(); setError(""); const override = {}; for (const input of $$(".override-input")) { if (input.value.trim()) override[input.dataset.role] = input.value.trim(); } const body = { template_path: tplSelect.value, repo_path: $("#repo-path").value.trim(), base_branch: $("#base-branch").value.trim() || "main", requirements_md: $("#requirements").value, }; if (Object.keys(override).length > 0) body.override = override; try { const r = await jsonFetch("/runs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); window.location.href = `/run.html?id=${r.run_id}`; } catch (e) { setError(`run 시작 실패: ${e.message}`); } }); } // =============== run.html =============== function getRunIdFromUrl() { const params = new URLSearchParams(window.location.search); return params.get("id"); } async function renderRunDetail() { setError(""); const runId = getRunIdFromUrl(); if (!runId) { setError("URL에 ?id= 가 필요합니다."); return; } $("#run-id").textContent = runId; try { const detail = await jsonFetch(`/runs/${runId}`); const stateBadgeHost = $("#run-state-badge"); stateBadgeHost.replaceChildren(badge(detail.run.state)); $("#run-repo").textContent = `${detail.run.repo_path} @ ${detail.run.base_branch}`; $("#run-worktree").textContent = detail.run.worktree_root; $("#run-report").textContent = detail.run.final_report_path || "—"; const phaseTbody = $("#phases tbody"); phaseTbody.replaceChildren(); if (detail.phases.length === 0) { phaseTbody.appendChild(emptyCell(5, "phase 기록이 없습니다.", null, null)); } else { for (const p of detail.phases) { const tr = document.createElement("tr"); const keyTd = document.createElement("td"); keyTd.className = "mono"; keyTd.textContent = p.phase_key; const stateTd = document.createElement("td"); stateTd.appendChild(badge(p.state)); const attTd = document.createElement("td"); attTd.textContent = String(p.attempts); const stTd = document.createElement("td"); stTd.className = "mono"; stTd.textContent = p.started_at ? p.started_at.slice(0, 19).replace("T", " ") : "—"; const endTd = document.createElement("td"); endTd.className = "mono"; endTd.textContent = p.ended_at ? p.ended_at.slice(0, 19).replace("T", " ") : "—"; tr.append(keyTd, stateTd, attTd, stTd, endTd); phaseTbody.appendChild(tr); } } const isTerminal = TERMINAL_STATES.has(detail.run.state); $("#resume-btn").disabled = isTerminal; $("#abort-btn").disabled = isTerminal; } catch (e) { setError(`run 정보를 불러오지 못했습니다: ${e.message}`); return; } startEventStream(runId); } function appendEventLine(eventsContainer, data) { const line = document.createElement("div"); const cls = (data.type || "").replace(/\./g, "-"); line.className = `event-line ${cls}`; const ts = document.createElement("div"); ts.className = "ts"; ts.textContent = (data.ts || "").slice(11, 19); const body = document.createElement("div"); body.className = "body"; const type = document.createElement("span"); type.className = "type"; type.textContent = data.type; body.appendChild(type); if (data.payload && Object.keys(data.payload).length > 0) { const payload = document.createElement("span"); payload.className = "payload"; payload.textContent = JSON.stringify(data.payload); body.appendChild(payload); } line.appendChild(ts); line.appendChild(body); eventsContainer.appendChild(line); eventsContainer.scrollTop = eventsContainer.scrollHeight; } let _eventSource = null; function startEventStream(runId) { if (_eventSource) { _eventSource.close(); } const eventsContainer = $("#events"); eventsContainer.replaceChildren(); const src = new EventSource(`${API}/runs/${runId}/events`); _eventSource = src; src.addEventListener("event", (ev) => { try { const data = JSON.parse(ev.data); appendEventLine(eventsContainer, data); } catch (_) { /* ignore parse errors */ } }); src.addEventListener("done", () => { src.close(); if (_eventSource === src) _eventSource = null; setTimeout(() => renderRunDetail(), 300); }); src.onerror = () => { src.close(); if (_eventSource === src) _eventSource = null; }; } async function abortRun() { const runId = getRunIdFromUrl(); if (!runId) return; if (!confirm("정말 이 run을 abort 할까요?")) return; try { await jsonFetch(`/runs/${runId}/abort`, { method: "POST" }); renderRunDetail(); } catch (e) { setError(`abort 실패: ${e.message}`); } } async function resumeRun() { const runId = getRunIdFromUrl(); if (!runId) return; try { await jsonFetch(`/runs/${runId}/resume`, { method: "POST" }); renderRunDetail(); } catch (e) { setError(`resume 실패: ${e.message}`); } } // =============== conversation page (v0.3 PR #8) =============== const CONV_STATE = { sessionId: null, eventSource: null, lastSeq: 0, awaitingReply: false, streamBuffer: "", // v0.4 B3: accumulated token deltas while streaming }; function $conv(sel) { return document.querySelector(sel); } function setSendDisabled(disabled) { $conv("#message-input").disabled = disabled; $conv("#send-btn").disabled = disabled; } // v0.4 B4: toggle the abort button visibility based on in-flight state. // `disabled` is what setSendDisabled sees AFTER awaiting reply has started. function setAbortVisible(visible) { const btn = $conv("#abort-btn"); if (!btn) return; btn.style.display = visible ? "inline-block" : "none"; btn.disabled = !visible; } function clearMessages() { const list = $conv("#messages"); list.replaceChildren(); } function showConversationEmpty(show, text) { let el = $conv("#conv-empty"); if (!el && show) { el = document.createElement("div"); el.id = "conv-empty"; el.className = "conv-empty"; $conv("#messages").appendChild(el); } if (el) { if (show) { el.textContent = text || "대화를 시작하세요."; el.style.display = ""; } else { el.remove(); } } } // v0.4 B1: minimal markdown renderer for assistant messages. // SECURITY: we ONLY emit DOM nodes built via createElement + textContent. // No innerHTML, no insertAdjacentHTML. This is a tiny subset of Markdown // chosen for chat readability — anything we don't understand is rendered as // literal text (textContent fallback in the default case). function _mdRenderInto(target, raw) { // Code-fence-aware splitter — we walk the input line-by-line and group // lines into blocks (paragraph, code-fence, h#, list). const lines = raw.split("\n"); let i = 0; while (i < lines.length) { const line = lines[i]; // Fenced code block: ```lang const fence = line.match(/^```\s*([\w.-]*)\s*$/); if (fence) { const lang = fence[1]; const codeLines = []; i++; while (i < lines.length && !/^```\s*$/.test(lines[i])) { codeLines.push(lines[i]); i++; } if (i < lines.length) i++; // consume closing ``` const pre = document.createElement("pre"); pre.className = "md-code"; const code = document.createElement("code"); if (lang) code.className = `language-${lang}`; code.textContent = codeLines.join("\n"); pre.appendChild(code); target.appendChild(pre); continue; } // ATX header: # / ## / ### (up to 6) const hdr = line.match(/^(#{1,6})\s+(.*)$/); if (hdr) { const level = hdr[1].length; const h = document.createElement(`h${level + 2 > 6 ? 6 : level + 2}`); h.className = "md-h"; _mdInline(h, hdr[2]); target.appendChild(h); i++; continue; } // Unordered list block — consecutive "- " or "* " if (/^[-*]\s+/.test(line)) { const ul = document.createElement("ul"); ul.className = "md-ul"; while (i < lines.length && /^[-*]\s+/.test(lines[i])) { const li = document.createElement("li"); _mdInline(li, lines[i].replace(/^[-*]\s+/, "")); ul.appendChild(li); i++; } target.appendChild(ul); continue; } // Ordered list: "1. ", "2. ", … if (/^\d+\.\s+/.test(line)) { const ol = document.createElement("ol"); ol.className = "md-ol"; while (i < lines.length && /^\d+\.\s+/.test(lines[i])) { const li = document.createElement("li"); _mdInline(li, lines[i].replace(/^\d+\.\s+/, "")); ol.appendChild(li); i++; } target.appendChild(ol); continue; } // Blank line — paragraph separator; skip. if (line.trim() === "") { i++; continue; } // Paragraph: greedily consume until blank or block-start. const paraLines = [line]; i++; while ( i < lines.length && lines[i].trim() !== "" && !/^```/.test(lines[i]) && !/^#{1,6}\s+/.test(lines[i]) && !/^[-*]\s+/.test(lines[i]) && !/^\d+\.\s+/.test(lines[i]) ) { paraLines.push(lines[i]); i++; } const p = document.createElement("p"); p.className = "md-p"; _mdInline(p, paraLines.join("\n")); target.appendChild(p); } } // Inline parser: handles `code`, **bold**, *italic*, [link](url). // Emits DOM nodes; never innerHTML. function _mdInline(target, text) { // Walk the string, matching the earliest-occurring inline pattern. let remaining = text; while (remaining.length > 0) { const matches = [ { re: /`([^`]+)`/, tag: "code" }, { re: /\*\*([^*\n]+)\*\*/, tag: "strong" }, { re: /(? 0) { target.appendChild(document.createTextNode(remaining.slice(0, best.hit.index))); } const el = document.createElement(best.tag); if (best.tag === "a") { // Link: cap protocol to http/https to avoid javascript: scheme escapes. const href = best.hit[2]; if (/^https?:\/\//.test(href)) el.href = href; el.rel = "noopener noreferrer"; el.target = "_blank"; el.textContent = best.hit[1]; } else { el.textContent = best.hit[1]; } target.appendChild(el); remaining = remaining.slice(best.hit.index + best.hit[0].length); } } // v0.4 B2: classify system messages into collapsible "event cards" so the // chat thread doesn't drown in [sub-agent ... spawned] / [workflow ... started] // notices. Returns a label + an emoji-style icon + whether to default to open. function _classifySystemMessage(content) { if (content.startsWith("[sub-agent")) { if (content.includes("result]")) return { label: "Sub-agent result", icon: "🤖", open: true }; if (content.includes("error]")) return { label: "Sub-agent error", icon: "⚠️", open: true }; return { label: "Sub-agent spawned", icon: "🚀", open: false }; } if (content.startsWith("[workflow")) { if (content.includes("started]")) return { label: "Workflow started", icon: "🛠️", open: false }; if (content.includes("failed]")) return { label: "Workflow failed", icon: "❌", open: true }; return { label: "Workflow event", icon: "✅", open: true }; } if (content.startsWith("Earlier conversation history")) { return { label: "Compaction summary", icon: "📝", open: false }; } if (content.startsWith("당신은 plan mode")) { return { label: "Plan mode activated", icon: "🧭", open: false }; } if (content.startsWith("The user APPROVED")) { return { label: "Approved plan", icon: "✅", open: false }; } if (content.startsWith("The user requested skill")) { return { label: "Skill activated", icon: "🪄", open: false }; } return null; } function appendMessageBubble(role, content, ts, opts) { showConversationEmpty(false); const list = $conv("#messages"); const bubble = document.createElement("div"); bubble.className = `msg-bubble role-${role}`; const meta = document.createElement("div"); meta.className = "msg-meta"; const roleSpan = document.createElement("span"); roleSpan.className = "msg-role"; roleSpan.textContent = role; const tsSpan = document.createElement("span"); tsSpan.className = "msg-ts"; tsSpan.textContent = (ts || "").slice(11, 19); meta.appendChild(roleSpan); if (ts) meta.appendChild(tsSpan); const body = document.createElement("div"); body.className = "msg-body"; if (role === "system") { // Collapsible event card if we recognise the format; otherwise plain. const cls = _classifySystemMessage(content); if (cls !== null) { bubble.classList.add("role-system-event"); const det = document.createElement("details"); det.className = "md-system-event"; if (cls.open) det.open = true; const sum = document.createElement("summary"); const icon = document.createElement("span"); icon.className = "event-icon"; icon.textContent = cls.icon; const label = document.createElement("span"); label.className = "event-label"; label.textContent = cls.label; sum.appendChild(icon); sum.appendChild(label); det.appendChild(sum); const inner = document.createElement("div"); inner.className = "event-body"; _mdRenderInto(inner, content); det.appendChild(inner); body.appendChild(det); } else { _mdRenderInto(body, content); } } else if (role === "assistant" || (opts && opts.renderMarkdown)) { _mdRenderInto(body, content); } else { body.textContent = content; } bubble.appendChild(meta); bubble.appendChild(body); list.appendChild(bubble); list.scrollTop = list.scrollHeight; return bubble; } function appendPendingPlaceholder() { const list = $conv("#messages"); const placeholder = document.createElement("div"); placeholder.id = "pending-placeholder"; placeholder.className = "msg-bubble role-assistant pending"; const meta = document.createElement("div"); meta.className = "msg-meta"; const roleSpan = document.createElement("span"); roleSpan.className = "msg-role"; roleSpan.textContent = "assistant"; meta.appendChild(roleSpan); const body = document.createElement("div"); body.className = "msg-body"; body.textContent = "…"; placeholder.appendChild(meta); placeholder.appendChild(body); list.appendChild(placeholder); list.scrollTop = list.scrollHeight; // v0.4 B3: keep a buffer for streamed tokens so we can re-render markdown // once the full text arrives. CONV_STATE.streamBuffer = ""; } function removePendingPlaceholder() { const p = $conv("#pending-placeholder"); if (p) p.remove(); CONV_STATE.streamBuffer = ""; } // v0.4 B3: append a streamed token to the pending placeholder's body. function appendStreamDelta(text) { const placeholder = $conv("#pending-placeholder"); if (!placeholder) return; if (!CONV_STATE.streamBuffer || CONV_STATE.streamBuffer === "") { // First chunk — replace the "…" indicator. const body = placeholder.querySelector(".msg-body"); if (body) body.textContent = ""; } CONV_STATE.streamBuffer = (CONV_STATE.streamBuffer || "") + text; const body = placeholder.querySelector(".msg-body"); if (body) { // Streaming view: keep plain text for speed, full markdown render only // happens when the final `message` event arrives. body.textContent = CONV_STATE.streamBuffer; } const list = $conv("#messages"); if (list) list.scrollTop = list.scrollHeight; } function updateSessionStatePill(state) { const pill = $conv("#session-state-pill"); if (!pill) return; if (!state) { pill.textContent = ""; pill.className = "conv-session-state"; return; } pill.textContent = state; pill.className = `conv-session-state state-${state}`; } async function loadSessionList() { try { const list = await jsonFetch("/sessions?limit=50"); const picker = $conv("#session-picker"); picker.replaceChildren(); const placeholderOpt = document.createElement("option"); placeholderOpt.value = ""; placeholderOpt.textContent = "(세션 선택…)"; picker.appendChild(placeholderOpt); for (const s of list) { const opt = document.createElement("option"); opt.value = s.id; const titleStr = s.title || "(제목 없음)"; opt.textContent = `${s.id.slice(0, 8)}… · ${titleStr}`; picker.appendChild(opt); } } catch (e) { setError(`세션 목록 로드 실패: ${e.message}`); } } async function loadAndAttachSession(sessionId) { if (CONV_STATE.eventSource) { CONV_STATE.eventSource.close(); CONV_STATE.eventSource = null; } CONV_STATE.sessionId = sessionId; CONV_STATE.lastSeq = 0; CONV_STATE.awaitingReply = false; clearMessages(); let detail; try { detail = await jsonFetch(`/sessions/${sessionId}`); } catch (e) { setError(`세션 로드 실패: ${e.message}`); setSendDisabled(true); return; } updateSessionStatePill(detail.session.state); const messages = detail.messages || []; for (const m of messages) { // v0.4 B2: render system messages too — most map to recognised event // cards (collapsible). Unknown system payloads fall through to plain // markdown rendering. appendMessageBubble(m.role, m.content, m.ts); if (m.seq > CONV_STATE.lastSeq) CONV_STATE.lastSeq = m.seq; } if (messages.length === 0) { showConversationEmpty(true, "이 세션에 메시지가 아직 없습니다. 첫 메시지를 보내보세요."); } const ended = detail.session.state === "ended"; setSendDisabled(ended); if (!ended) attachEventSource(sessionId); } function attachEventSource(sessionId) { if (CONV_STATE.eventSource) { CONV_STATE.eventSource.close(); } const url = `${API}/sessions/${sessionId}/stream?last_seq=${CONV_STATE.lastSeq}`; const src = new EventSource(url); CONV_STATE.eventSource = src; src.addEventListener("message", (ev) => { try { const data = JSON.parse(ev.data); if (data.seq <= CONV_STATE.lastSeq) return; if (data.role === "assistant" && CONV_STATE.awaitingReply) { removePendingPlaceholder(); CONV_STATE.awaitingReply = false; setAbortVisible(false); } // v0.4 B2: render every system message — most are recognised events // (compaction / sub-agent / workflow / plan / skill) and rendered as // collapsible cards by appendMessageBubble. appendMessageBubble(data.role, data.content, data.ts); CONV_STATE.lastSeq = data.seq; } catch (_) { /* ignore parse errors */ } }); // v0.4 B3: token streaming. Server pushes one chunk per LLM token; we // append to the pending placeholder. When the final "message" SSE arrives // it replaces the streaming text with the markdown-rendered version. src.addEventListener("chunk", (ev) => { try { const data = JSON.parse(ev.data); if (data.type === "delta" && typeof data.text === "string") { appendStreamDelta(data.text); } else if (data.type === "cancelled" || data.type === "error") { // Drop the placeholder; setError already handled or will be by 'message'. removePendingPlaceholder(); CONV_STATE.awaitingReply = false; setAbortVisible(false); } // type === "done" is benign — the matching 'message' SSE arrives next. } catch (_) { /* ignore parse errors */ } }); src.addEventListener("done", () => { src.close(); if (CONV_STATE.eventSource === src) CONV_STATE.eventSource = null; updateSessionStatePill("ended"); setSendDisabled(true); }); src.onerror = () => { // Sessions are long-lived — let the browser reconnect on EventSource's // default backoff. We don't surface this as a hard error unless it // persists. }; } async function sendMessage(text) { if (!CONV_STATE.sessionId) { setError("세션을 먼저 선택하거나 새로 만드세요."); return; } if (!text.trim()) return; setSendDisabled(true); setAbortVisible(true); CONV_STATE.awaitingReply = true; appendPendingPlaceholder(); try { await jsonFetch(`/sessions/${CONV_STATE.sessionId}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: text }), }); $conv("#message-input").value = ""; setError(""); } catch (e) { removePendingPlaceholder(); CONV_STATE.awaitingReply = false; setAbortVisible(false); setError(`전송 실패: ${e.message}`); } finally { setSendDisabled(false); $conv("#message-input").focus(); } } async function abortInflight() { if (!CONV_STATE.sessionId) return; try { await jsonFetch(`/sessions/${CONV_STATE.sessionId}/abort`, { method: "POST" }); removePendingPlaceholder(); CONV_STATE.awaitingReply = false; setAbortVisible(false); setError(""); } catch (e) { setError(`중단 실패: ${e.message}`); } } async function createNewSession() { let personas; try { personas = await jsonFetch("/personas"); } catch (e) { setError(`persona 목록 로드 실패: ${e.message}`); return; } const defaultPersona = personas.find((p) => p.name === "default-interactive") || personas[0]; if (!defaultPersona) { setError("등록된 persona 가 없습니다. CLI 에서 `mydeepagent` 한 번 실행한 후 재시도하세요."); return; } try { const ack = await jsonFetch("/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, // CreateSessionRequest requires repo_path min_length=1. We default to // "." (cwd of the serve process) — the backend resolves it to absolute. body: JSON.stringify({ persona_name: defaultPersona.name, repo_path: "." }), }); await loadSessionList(); $conv("#session-picker").value = ack.session_id; await loadAndAttachSession(ack.session_id); } catch (e) { setError(`세션 생성 실패: ${e.message}`); } } async function bootstrapConversationPage() { await loadSessionList(); $conv("#new-session-btn").addEventListener("click", createNewSession); $conv("#abort-btn").addEventListener("click", abortInflight); $conv("#session-picker").addEventListener("change", (ev) => { const sid = ev.target.value; if (sid) loadAndAttachSession(sid); }); $conv("#message-form").addEventListener("submit", (ev) => { ev.preventDefault(); const input = $conv("#message-input"); sendMessage(input.value); }); // v0.4 B5: track IME composition state — Korean/Japanese/Chinese IME emits // Enter to commit the current candidate; we must NOT treat that as send. // compositionend ALSO fires a synthetic Enter that we need to swallow. const input = $conv("#message-input"); input._composing = false; input.addEventListener("compositionstart", () => { input._composing = true; }); input.addEventListener("compositionend", () => { // The keydown event that ends composition is still pending — defer the // flag flip one tick so the upcoming keydown still sees _composing=true. setTimeout(() => { input._composing = false; }, 0); }); input.addEventListener("keydown", (ev) => { // Honor Cmd/Ctrl+Enter as explicit "send" override even during composition. if ((ev.metaKey || ev.ctrlKey) && ev.key === "Enter") { ev.preventDefault(); sendMessage(ev.target.value); return; } // Plain Enter during composition (e.g. Korean IME committing 한글) must // pass through to the textarea — do nothing. if (ev.key === "Enter" && input._composing) { return; } }); // v0.3 PR #8: deep link `?session=` auto-loads the named session. const params = new URLSearchParams(window.location.search); const deepSid = params.get("session"); if (deepSid) { const picker = $conv("#session-picker"); if (picker) picker.value = deepSid; loadAndAttachSession(deepSid); } } // =============== sessions list (index.html as of v0.3 PR #8) =============== async function renderSessionsList() { setError(""); let sessions; try { sessions = await jsonFetch("/sessions?limit=50"); } catch (e) { setError(`세션 목록을 불러오지 못했습니다: ${e.message}`); return; } const tbody = $("#sessions tbody"); if (!tbody) return; tbody.replaceChildren(); if (sessions.length === 0) { tbody.appendChild( emptyCell(5, "아직 대화한 세션이 없습니다.", "/conversation.html", "새 대화 시작 →") ); return; } for (const s of sessions) { const tr = document.createElement("tr"); const idTd = document.createElement("td"); const idLink = document.createElement("a"); idLink.href = `/conversation.html?session=${s.id}`; idLink.className = "mono"; idLink.textContent = s.id.slice(0, 8) + "…"; idTd.appendChild(idLink); const stateTd = document.createElement("td"); stateTd.appendChild(badge(s.state)); const titleTd = document.createElement("td"); titleTd.textContent = s.title || "(no title yet)"; const personaTd = document.createElement("td"); personaTd.className = "mono"; // SessionSummary exposes `persona_id` (UUID) — show first 8 chars + tooltip. if (s.persona_id) { personaTd.textContent = s.persona_id.slice(0, 8) + "…"; personaTd.title = s.persona_id; } else { personaTd.textContent = "—"; } const lastTd = document.createElement("td"); lastTd.className = "mono"; lastTd.textContent = (s.last_message_at || s.started_at || "").slice(0, 19).replace("T", " "); tr.append(idTd, stateTd, titleTd, personaTd, lastTd); tbody.appendChild(tr); } } // =============== new-workflow.html (v0.4 generator) =============== const _CAPABILITIES = [ "spec_write", "code_review", "evidence_check", "log_analysis", "decision", "command_execute", "security_audit", "code_edit", "plan", "verify", ]; const _BACKENDS = ["openrouter", "anthropic", "ollama_local"]; const _RISKS = ["low", "medium", "high"]; const WF_STATE = { roles: /** @type {Array<{id:string,capabilities:string[],backends:string[],fallbacks:string[]}>} */ ([]), phases: /** @type {Array<{key:string,title:string,risk:string,role:string,instructions:string,artifactPath:string,artifactSchema:string,gates:string,timeout:string,budget:string}>} */ ([]), }; function _wfFreshRole() { return { id: "", capabilities: [], backends: [], fallbacks: [] }; } function _wfFreshPhase() { return { key: "", title: "", risk: "medium", role: "", instructions: "", artifactPath: "", artifactSchema: "", gates: "", timeout: "", budget: "", }; } function _wfChip(label, checked, onChange) { const lbl = document.createElement("label"); lbl.className = "wf-chip"; const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = checked; cb.addEventListener("change", () => onChange(cb.checked)); const span = document.createElement("span"); span.textContent = label; lbl.appendChild(cb); lbl.appendChild(span); return lbl; } function _wfTextInput(value, placeholder, onChange, type = "text") { const i = document.createElement("input"); i.type = type; i.value = value; i.placeholder = placeholder; i.addEventListener("input", () => onChange(i.value)); return i; } function _wfTextArea(value, placeholder, onChange, rows = 3) { const t = document.createElement("textarea"); t.value = value; t.placeholder = placeholder; t.rows = rows; t.addEventListener("input", () => onChange(t.value)); return t; } function _wfSelect(value, options, onChange) { const s = document.createElement("select"); for (const o of options) { const opt = document.createElement("option"); opt.value = o; opt.textContent = o; if (o === value) opt.selected = true; s.appendChild(opt); } s.addEventListener("change", () => onChange(s.value)); return s; } function renderRolesList() { const container = $("#roles-list"); if (!container) return; container.replaceChildren(); WF_STATE.roles.forEach((role, idx) => { const card = document.createElement("div"); card.className = "wf-row-card"; const header = document.createElement("div"); header.className = "wf-row-header"; const title = document.createElement("strong"); title.textContent = `Role #${idx + 1}`; const del = document.createElement("button"); del.type = "button"; del.className = "button-link"; del.textContent = "삭제"; del.addEventListener("click", () => { WF_STATE.roles.splice(idx, 1); renderRolesList(); renderPreview(); }); header.append(title, del); card.appendChild(header); const idRow = document.createElement("div"); idRow.className = "form-row"; const idLbl = document.createElement("label"); idLbl.innerHTML = "id — phase 가 참조할 키. writer 같은 소문자/숫자/언더스코어"; idRow.append(idLbl, _wfTextInput(role.id, "writer", (v) => { role.id = v; renderPreview(); })); card.appendChild(idRow); const capRow = document.createElement("div"); capRow.className = "form-row"; const capLbl = document.createElement("label"); capLbl.innerHTML = "required_capabilities — persona 가 가져야 할 능력 (최소 1)"; const chips = document.createElement("div"); chips.className = "chips"; for (const c of _CAPABILITIES) { chips.appendChild(_wfChip(c, role.capabilities.includes(c), (on) => { if (on && !role.capabilities.includes(c)) role.capabilities.push(c); else if (!on) role.capabilities = role.capabilities.filter((x) => x !== c); renderPreview(); })); } capRow.append(capLbl, chips); card.appendChild(capRow); container.appendChild(card); }); if (WF_STATE.roles.length === 0) { const empty = document.createElement("div"); empty.className = "hint"; empty.textContent = "Role 이 1개 이상 필요합니다."; container.appendChild(empty); } } function renderPhasesList() { const container = $("#phases-list"); if (!container) return; container.replaceChildren(); const roleIds = WF_STATE.roles.map((r) => r.id).filter(Boolean); WF_STATE.phases.forEach((phase, idx) => { const card = document.createElement("div"); card.className = "wf-row-card"; const header = document.createElement("div"); header.className = "wf-row-header"; const title = document.createElement("strong"); title.textContent = `Phase #${idx + 1}`; const del = document.createElement("button"); del.type = "button"; del.className = "button-link"; del.textContent = "삭제"; del.addEventListener("click", () => { WF_STATE.phases.splice(idx, 1); renderPhasesList(); renderPreview(); }); header.append(title, del); card.appendChild(header); const grid = document.createElement("div"); grid.className = "form-grid"; for (const [label, key, ph] of [ ["key — 영문 소문자/숫자/언더스코어", "key", "spec"], ["title — 표시용 한 줄", "title", "명세 작성"], ]) { const r = document.createElement("div"); r.className = "form-row"; const l = document.createElement("label"); l.textContent = label; r.append(l, _wfTextInput(phase[key], ph, (v) => { phase[key] = v; renderPreview(); })); grid.appendChild(r); } card.appendChild(grid); const grid2 = document.createElement("div"); grid2.className = "form-grid"; const riskRow = document.createElement("div"); riskRow.className = "form-row"; const riskLbl = document.createElement("label"); riskLbl.innerHTML = "risk — 단계 위험 등급"; riskRow.append(riskLbl, _wfSelect(phase.risk, _RISKS, (v) => { phase.risk = v; renderPreview(); })); grid2.appendChild(riskRow); const roleRow = document.createElement("div"); roleRow.className = "form-row"; const roleLbl = document.createElement("label"); roleLbl.innerHTML = "role — 위에서 정의한 role id 중 하나"; const opts = roleIds.length > 0 ? roleIds : ["(role 을 먼저 정의)"]; roleRow.append(roleLbl, _wfSelect(phase.role, opts, (v) => { phase.role = v; renderPreview(); })); grid2.appendChild(roleRow); card.appendChild(grid2); const insRow = document.createElement("div"); insRow.className = "form-row"; const insLbl = document.createElement("label"); insLbl.innerHTML = "instructions — 최소 10자. 이 phase 가 무엇을 해야 하는지"; insRow.append(insLbl, _wfTextArea(phase.instructions, "예: requirements.md 를 읽고 spec.md 를 작성하세요. 한국어 권장.", (v) => { phase.instructions = v; renderPreview(); }, 4)); card.appendChild(insRow); const grid3 = document.createElement("div"); grid3.className = "form-grid"; for (const [label, key, ph] of [ ["expected_artifact.path (선택)", "artifactPath", "artifacts/spec.md"], ["expected_artifact.schema (선택)", "artifactSchema", "spec-v1"], ]) { const r = document.createElement("div"); r.className = "form-row"; const l = document.createElement("label"); l.textContent = label; r.append(l, _wfTextInput(phase[key], ph, (v) => { phase[key] = v; renderPreview(); })); grid3.appendChild(r); } card.appendChild(grid3); container.appendChild(card); }); if (WF_STATE.phases.length === 0) { const empty = document.createElement("div"); empty.className = "hint"; empty.textContent = "Phase 가 1개 이상 필요합니다."; container.appendChild(empty); } } function _wfBuildRequest() { const name = $("#wf-name").value.trim(); const version = parseInt($("#wf-version").value, 10); const description = $("#wf-description").value.trim(); const roles = WF_STATE.roles.filter((r) => r.id).map((r) => ({ id: r.id, required_capabilities: r.capabilities, preferred_backends: r.backends, fallback_personas: r.fallbacks, })); const phases = WF_STATE.phases.filter((p) => p.key).map((p) => { const out = { key: p.key, title: p.title || p.key, risk: p.risk, role: p.role, instructions: p.instructions || "(no instructions)", gates: [], }; if (p.artifactPath || p.artifactSchema) { out.expected_artifact = { path: p.artifactPath || "artifacts/output.md", schema: p.artifactSchema || "text", }; } return out; }); const req = { name, version: isNaN(version) ? 1 : version, roles, phases, default_gates: [] }; if (description) req.description = description; return req; } function renderPreview() { const pre = $("#wf-preview"); if (!pre) return; pre.textContent = JSON.stringify(_wfBuildRequest(), null, 2); } async function submitWorkflow(ev) { ev.preventDefault(); setError(""); $("#success").style.display = "none"; const req = _wfBuildRequest(); try { const ack = await jsonFetch("/workflows", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(req), }); const okBox = $("#success"); okBox.textContent = `✅ 저장 완료 → ${ack.path}. 워크플로우 실행 페이지에서 바로 보입니다.`; okBox.style.display = "block"; } catch (e) { setError(`저장 실패: ${e.message}`); } } function bootstrapWorkflowGenerator() { WF_STATE.roles = [_wfFreshRole()]; WF_STATE.phases = [_wfFreshPhase()]; renderRolesList(); renderPhasesList(); renderPreview(); $("#add-role").addEventListener("click", () => { WF_STATE.roles.push(_wfFreshRole()); renderRolesList(); renderPreview(); }); $("#add-phase").addEventListener("click", () => { WF_STATE.phases.push(_wfFreshPhase()); renderPhasesList(); renderPreview(); }); $("#wf-name").addEventListener("input", renderPreview); $("#wf-version").addEventListener("input", renderPreview); $("#wf-description").addEventListener("input", renderPreview); $("#wf-form").addEventListener("submit", submitWorkflow); } // =============== bootstrap =============== document.addEventListener("DOMContentLoaded", () => { const page = document.body.dataset.page; if (page === "index") { renderSessionsList(); } else if (page === "runs") { renderRunsList(); renderBudgetSummary(); } else if (page === "new") { renderNewRunForm(); } else if (page === "run") { renderRunDetail(); $("#abort-btn").addEventListener("click", abortRun); $("#resume-btn").addEventListener("click", resumeRun); } else if (page === "conversation") { bootstrapConversationPage(); } else if (page === "new-workflow") { bootstrapWorkflowGenerator(); } });