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>
This commit is contained in:
chungyeong
2026-05-18 01:08:40 +09:00
parent 6d371afadd
commit 9a02f22acb
9 changed files with 1169 additions and 57 deletions

View File

@@ -424,6 +424,7 @@ const CONV_STATE = {
eventSource: null,
lastSeq: 0,
awaitingReply: false,
streamBuffer: "", // v0.4 B3: accumulated token deltas while streaming
};
function $conv(sel) { return document.querySelector(sel); }
@@ -433,6 +434,15 @@ function setSendDisabled(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();
@@ -456,7 +466,179 @@ function showConversationEmpty(show, text) {
}
}
function appendMessageBubble(role, content, ts) {
// 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");
@@ -471,13 +653,47 @@ function appendMessageBubble(role, content, ts) {
tsSpan.textContent = (ts || "").slice(11, 19);
meta.appendChild(roleSpan);
if (ts) meta.appendChild(tsSpan);
const body = document.createElement("div");
body.className = "msg-body";
body.textContent = content;
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() {
@@ -485,14 +701,48 @@ function appendPendingPlaceholder() {
const placeholder = document.createElement("div");
placeholder.id = "pending-placeholder";
placeholder.className = "msg-bubble role-assistant pending";
placeholder.textContent = "…";
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) {
@@ -550,7 +800,9 @@ async function loadAndAttachSession(sessionId) {
const messages = detail.messages || [];
for (const m of messages) {
if (m.role === "system" && !m.is_summary) continue;
// 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;
}
@@ -578,17 +830,34 @@ function attachEventSource(sessionId) {
if (data.role === "assistant" && CONV_STATE.awaitingReply) {
removePendingPlaceholder();
CONV_STATE.awaitingReply = false;
setAbortVisible(false);
}
// Skip system messages except summaries.
if (data.role === "system" && !data.is_summary) {
CONV_STATE.lastSeq = data.seq;
return;
}
// 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;
@@ -610,6 +879,7 @@ async function sendMessage(text) {
}
if (!text.trim()) return;
setSendDisabled(true);
setAbortVisible(true);
CONV_STATE.awaitingReply = true;
appendPendingPlaceholder();
try {
@@ -623,6 +893,7 @@ async function sendMessage(text) {
} catch (e) {
removePendingPlaceholder();
CONV_STATE.awaitingReply = false;
setAbortVisible(false);
setError(`전송 실패: ${e.message}`);
} finally {
setSendDisabled(false);
@@ -630,6 +901,19 @@ async function sendMessage(text) {
}
}
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 {
@@ -647,7 +931,9 @@ async function createNewSession() {
const ack = await jsonFetch("/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ persona_name: defaultPersona.name, repo_path: "" }),
// 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;
@@ -660,6 +946,7 @@ async function createNewSession() {
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);
@@ -669,10 +956,28 @@ async function bootstrapConversationPage() {
const input = $conv("#message-input");
sendMessage(input.value);
});
$conv("#message-input").addEventListener("keydown", (ev) => {
// 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.

View File

@@ -44,6 +44,7 @@
disabled
></textarea>
<button id="send-btn" type="submit" disabled>전송</button>
<button id="abort-btn" type="button" disabled style="display:none">⏹ 중단</button>
</form>
</main>
<script src="/static/app.js"></script>

View File

@@ -1093,3 +1093,121 @@ details[open] summary {
border-color: rgba(180, 70, 30, 0.5);
font-weight: 600;
}
/* =================================================================
v0.4 — Markdown + system event cards in conversation
================================================================= */
.msg-body .md-p {
margin: 0 0 8px 0;
line-height: 1.6;
}
.msg-body .md-p:last-child { margin-bottom: 0; }
.msg-body .md-h {
margin: 12px 0 6px 0;
font-weight: 700;
line-height: 1.3;
}
.msg-body .md-ul,
.msg-body .md-ol {
margin: 4px 0 8px 0;
padding-left: 22px;
line-height: 1.6;
}
.msg-body .md-ul li,
.msg-body .md-ol li {
margin: 2px 0;
}
.msg-body .md-code {
background: rgba(0, 0, 0, 0.04);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
margin: 8px 0;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 12.5px;
line-height: 1.45;
}
.msg-body .md-code code {
background: transparent;
padding: 0;
}
.msg-body code {
background: rgba(0, 0, 0, 0.06);
border-radius: 4px;
padding: 1px 5px;
font-family: var(--font-mono);
font-size: 0.9em;
}
.msg-bubble.role-user .msg-body .md-code,
.msg-bubble.role-user .msg-body code {
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.3);
color: white;
}
.msg-body a {
color: rgb(180, 70, 30);
text-decoration: underline;
text-underline-offset: 2px;
}
.msg-bubble.role-user .msg-body a {
color: white;
}
.msg-body strong { font-weight: 700; }
.msg-body em { font-style: italic; }
/* System event card */
.msg-bubble.role-system-event {
align-self: stretch;
max-width: 100%;
background: rgba(245, 158, 11, 0.06);
border: 1px solid rgba(245, 158, 11, 0.25);
border-style: dashed;
font-style: normal;
color: var(--text);
}
.md-system-event summary {
cursor: pointer;
font-size: 12.5px;
display: flex;
align-items: center;
gap: 6px;
list-style: none;
}
.md-system-event summary::-webkit-details-marker { display: none; }
.md-system-event summary .event-icon {
font-size: 14px;
}
.md-system-event summary .event-label {
font-weight: 600;
letter-spacing: 0.02em;
color: rgb(120, 53, 15);
}
.md-system-event[open] summary {
margin-bottom: 8px;
border-bottom: 1px dashed rgba(245, 158, 11, 0.3);
padding-bottom: 6px;
}
.md-system-event .event-body {
font-size: 12.5px;
line-height: 1.55;
color: var(--text-muted);
}