feat(my-deepagent): v0.3 PR #8 — conversation-centric Web GUI (/conversation.html)

Workflow run 페이지를 archive 로 격하시키고, 사용자가 처음 보는 화면을
chat-style 대화 thread 로 전환.  Claude Code 의 Web GUI 와 동일한 UX.

핵심 동작:
- 새 페이지 `/conversation.html` 에서 세션을 picker 로 고르거나 "새 대화"
  버튼으로 만들고 메시지 입력.  Cmd/Ctrl+Enter 로 전송.
- POST /api/sessions/{id}/messages 가 user MessageRow 를 영속한 즉시 200 응답
  후 `asyncio.create_task(invoke_session_agent(...))` 로 백그라운드 invoke 발사.
- 백그라운드 task 는 lifespan 에서 1회 열어둔 LangGraph saver 를 재사용하고
  agent.ainvoke → assistant MessageRow 영속 → 자동 compaction 까지 처리.
- 기존 SSE 스트림 (`/api/sessions/{id}/stream`) 이 새 메시지를 push,
  프론트엔드의 `EventSource` 가 받아 thread 에 렌더.

신규 / 수정 파일:
- `static/conversation.html` (신규): chat UI 마크업.  data-page="conversation".
- `static/app.js`: 새 페이지 핸들러 `bootstrapConversationPage` +
  세션 picker + 메시지 thread 렌더 + SSE 구독 + Cmd/Ctrl+Enter 단축키.
  XSS 정책 동일: 모든 사용자 콘텐츠는 `textContent` 만 사용.
- `static/style.css`: `.messages-thread`, `.msg-bubble`, `.conv-topbar`,
  `.conv-input-bar` 등 chat UI 스타일.
- `api/app.py`: lifespan 에서 LangGraph saver 를 1회 열어 `app.state.saver`
  에 보관 (Postgres 일 때만).
- `api/agent_runner.py` (신규): `invoke_session_agent(...)` — REPL 의
  `InteractiveSession + _invoke_and_stream` 와 동일한 stack 을 HTTP background
  context 용으로 재구성.  실패는 로깅 + return.
- `api/routes/sessions.py`: POST /messages 가 background task 발사 + ref 를
  `app.state.pending_invocations` set 에 보관 (RUF006 / GC drop 방지).

테스트 (`tests/integration/test_conversation_gui.py`, 4 케이스):
- GET /conversation.html → 200 + 필수 마크업
- POST /messages → 200 + user row 영속 + 스텁 runner 호출 확인
- 백그라운드 task ref 가 `pending_invocations` 에 잡혀있고 완료 후 자동 discard
- 스텁 runner 가 assistant row 영속 → user + assistant 시퀀스 검증

게이트:
- ruff check / format --check / mypy: PASS
- pytest -q --ignore=tests/integration/test_e2e_workflow.py
  --ignore=tests/integration/test_openrouter_smoke.py: 675 passed (4 신규 포함)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chungyeong
2026-05-17 21:03:09 +09:00
parent 61b34af0e4
commit e326c07dcb
8 changed files with 1067 additions and 1 deletions

View File

@@ -417,6 +417,266 @@ async function resumeRun() {
}
}
// =============== conversation page (v0.3 PR #8) ===============
const CONV_STATE = {
sessionId: null,
eventSource: null,
lastSeq: 0,
awaitingReply: false,
};
function $conv(sel) { return document.querySelector(sel); }
function setSendDisabled(disabled) {
$conv("#message-input").disabled = disabled;
$conv("#send-btn").disabled = disabled;
}
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();
}
}
}
function appendMessageBubble(role, content, ts) {
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";
body.textContent = content;
bubble.appendChild(meta);
bubble.appendChild(body);
list.appendChild(bubble);
list.scrollTop = list.scrollHeight;
}
function appendPendingPlaceholder() {
const list = $conv("#messages");
const placeholder = document.createElement("div");
placeholder.id = "pending-placeholder";
placeholder.className = "msg-bubble role-assistant pending";
placeholder.textContent = "…";
list.appendChild(placeholder);
list.scrollTop = list.scrollHeight;
}
function removePendingPlaceholder() {
const p = $conv("#pending-placeholder");
if (p) p.remove();
}
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) {
if (m.role === "system" && !m.is_summary) continue;
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;
}
// Skip system messages except summaries.
if (data.role === "system" && !data.is_summary) {
CONV_STATE.lastSeq = data.seq;
return;
}
appendMessageBubble(data.role, data.content, data.ts);
CONV_STATE.lastSeq = data.seq;
} 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);
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;
setError(`전송 실패: ${e.message}`);
} finally {
setSendDisabled(false);
$conv("#message-input").focus();
}
}
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" },
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}`);
}
}
function bootstrapConversationPage() {
loadSessionList();
$conv("#new-session-btn").addEventListener("click", createNewSession);
$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);
});
$conv("#message-input").addEventListener("keydown", (ev) => {
if ((ev.metaKey || ev.ctrlKey) && ev.key === "Enter") {
ev.preventDefault();
sendMessage(ev.target.value);
}
});
}
// =============== bootstrap ===============
document.addEventListener("DOMContentLoaded", () => {
@@ -430,5 +690,7 @@ document.addEventListener("DOMContentLoaded", () => {
renderRunDetail();
$("#abort-btn").addEventListener("click", abortRun);
$("#resume-btn").addEventListener("click", resumeRun);
} else if (page === "conversation") {
bootstrapConversationPage();
}
});

View File

@@ -0,0 +1,49 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>my-deepagent · 대화</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body data-page="conversation">
<header>
<h1><a href="/">my-deepagent</a></h1>
<nav>
<a href="/conversation.html" class="active">대화</a>
<a href="/">Runs (아카이브)</a>
</nav>
</header>
<main class="conversation-main">
<div id="error" class="error-banner" style="display:none"></div>
<!-- Top bar: session picker + new conversation button -->
<div class="conv-topbar">
<label for="session-picker" class="conv-label">세션</label>
<select id="session-picker" class="conv-picker">
<option value="">(세션 선택…)</option>
</select>
<button id="new-session-btn" type="button" class="conv-action-btn">새 대화</button>
<span class="conv-session-state" id="session-state-pill"></span>
</div>
<!-- Message thread -->
<div id="messages" class="messages-thread">
<div class="conv-empty" id="conv-empty">대화를 시작하려면 위에서 세션을 선택하거나 "새 대화"를 누르세요.</div>
</div>
<!-- Input bar -->
<form id="message-form" class="conv-input-bar">
<textarea
id="message-input"
rows="2"
placeholder="메시지를 입력하세요… (Cmd/Ctrl+Enter 로 전송)"
autocomplete="off"
disabled
></textarea>
<button id="send-btn" type="submit" disabled>전송</button>
</form>
</main>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -778,3 +778,187 @@ select {
.event-line { grid-template-columns: 1fr; gap: 2px; }
.chips { grid-template-columns: 1fr; gap: 6px; }
}
/* =================================================================
v0.3 PR #8 — Conversation page
================================================================= */
.conversation-main {
display: flex;
flex-direction: column;
min-height: calc(100vh - 80px);
padding-bottom: 0;
}
.conv-topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.conv-label {
font-size: 13px;
color: var(--text-muted);
font-weight: 600;
}
.conv-picker {
flex: 1;
min-width: 240px;
padding: 6px 10px;
font-family: var(--font-mono);
font-size: 13px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
}
.conv-action-btn {
padding: 6px 14px;
font-size: 13px;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.conv-action-btn:hover { filter: brightness(1.08); }
.conv-session-state {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
text-transform: lowercase;
letter-spacing: 0.04em;
}
.conv-session-state.state-active {
background: rgba(34,197,94,0.12);
color: rgb(22,163,74);
}
.conv-session-state.state-ended {
background: rgba(100,116,139,0.12);
color: rgb(71,85,105);
}
.messages-thread {
flex: 1;
overflow-y: auto;
padding: 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
margin-bottom: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.conv-empty {
color: var(--text-muted);
text-align: center;
padding: 40px 16px;
font-size: 13px;
}
.msg-bubble {
max-width: 80%;
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.msg-bubble.role-user {
align-self: flex-end;
background: var(--accent);
color: white;
}
.msg-bubble.role-assistant {
align-self: flex-start;
background: var(--bg-card);
border: 1px solid var(--border);
}
.msg-bubble.role-system {
align-self: center;
max-width: 90%;
font-style: italic;
font-size: 12.5px;
background: rgba(245,158,11,0.08);
border: 1px dashed rgba(245,158,11,0.4);
color: rgb(120,53,15);
}
.msg-bubble.pending {
opacity: 0.6;
font-size: 20px;
padding: 6px 14px;
}
.msg-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
opacity: 0.6;
margin-bottom: 4px;
}
.msg-role {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.conv-input-bar {
display: flex;
gap: 8px;
padding: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
}
.conv-input-bar textarea {
flex: 1;
font-family: var(--font-body);
font-size: 14px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
resize: vertical;
min-height: 44px;
}
.conv-input-bar textarea:disabled {
background: var(--bg);
opacity: 0.5;
}
.conv-input-bar button {
padding: 0 18px;
font-size: 13px;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.conv-input-bar button:disabled {
opacity: 0.4;
cursor: not-allowed;
}