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>
1341 lines
45 KiB
JavaScript
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();
|
|
}
|
|
});
|