Files
dev-puppeteer/my-deepagent/static/app.js
chungyeong 9a02f22acb feat(my-deepagent): v0.4 chat UX boost + A/B live verification
Claude-Code 동급 chat 경험으로 끌어올림 + 7개 핵심 흐름 실제 OpenRouter verify.

A — Live verification (scripts/live_verify.py, 7 PASS, 약 $0.02):
- A1 1-turn chat (CLI-eq) → Haiku 4.5 한국어 응답
- A2 sessions resume → 같은 session_id 재투입 시 LangGraph state 복원
- A3 /skill <name> system inject → SKILL.md ("한국어 haiku 3 lines") 가 정확히
  3행 한국어 시 생성 (LLM 행동 제어 강력한 증거)
- A4 /plan → /approve → LLM plan markdown only, 차단 도구 시도 없음
- A5 /agents spawn → 실제 sub-agent ainvoke + parent stream push
- A6 auto-compaction → 14 메시지 → 4 archive + 77 토큰 summary
- A7 /workflow wiring → role↔persona 매칭 사전 검증

B1 — Markdown rendering:
- app.js pure-JS 미니 파서: 코드 펜스 / ATX 헤더 / ul/ol / `code`/**bold**/
  *italic*/[link](url)
- XSS 정책 유지: createElement + textContent only.  링크 href 는 http(s):
  스킴 강제.

B2 — System event card (collapsible):
- _classifySystemMessage 가 [sub-agent .../workflow .../Earlier conversation
  history/당신은 plan mode/The user APPROVED/skill] 접두사 분류 후 <details>
  카드로 렌더.

B3 — Token streaming via AsyncCallbackHandler:
- ChatOpenAI(streaming=True)
- _StreamingChunkPusher (AsyncCallbackHandler) → asyncio.Queue per session.
- SSE _session_event_stream 이 queue drain → event: chunk SSE.  100ms poll.
- 순서 보장: chunk drain → message rows yield (placeholder 가 메시지로
  교체되기 전에 토큰 visible).
- 라이브: 5 chunk events + 1 final message, "안녕하세요, / 무 / 엇을 도와드 /
  릴까요?" 토큰 단위 push.

B4 — Cancel mid-turn:
- POST /api/sessions/{id}/abort + app.state.pending_per_session 인덱스.
- 새 user 메시지 도착 시 이전 in-flight task 자동 cancel.
- "■ 중단" 버튼 — 대기 중 visible, 완료/취소 시 hide.

B5 — IME composition-safe Enter:
- compositionstart/compositionend 플래그 — 한글 IME 후보 commit Enter 무시.
- Cmd/Ctrl+Enter 는 항상 전송.

DB hot-fix:
- Database.__init__ pool_pre_ping=True — Postgres asyncpg stale connection
  → SSE 부하에서 500 발생 해결.

기타:
- createNewSession 의 repo_path: "" → "." (min_length=1 검증 통과).
- test_conversation_gui.py fake_invoke 가 chunk_queue kwarg 받도록 업데이트.

게이트:
- ruff / format / mypy: PASS (143 source files)
- pytest -q --ignore=tests/integration/test_e2e_workflow.py
  --ignore=tests/integration/test_openrouter_smoke.py: 709 passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 01:08:40 +09:00

1341 lines
45 KiB
JavaScript

/* 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=<run_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: /(?<!\*)\*([^*\n]+)\*(?!\*)/, tag: "em" },
{ re: /\[([^\]]+)\]\(([^)\s]+)\)/, tag: "a" },
];
let best = null;
for (const m of matches) {
const hit = remaining.match(m.re);
if (hit && (best === null || hit.index < best.hit.index)) {
best = { hit, tag: m.tag };
}
}
if (best === null) {
target.appendChild(document.createTextNode(remaining));
return;
}
if (best.hit.index > 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=<id>` 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 <span class='hint'>— phase 가 참조할 키. <code>writer</code> 같은 소문자/숫자/언더스코어</span>";
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 <span class='hint'>— persona 가 가져야 할 능력 (최소 1)</span>";
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 <span class='hint'>— 단계 위험 등급</span>";
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 <span class='hint'>— 위에서 정의한 role id 중 하나</span>";
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 <span class='hint'>— 최소 10자. 이 phase 가 무엇을 해야 하는지</span>";
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();
}
});