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:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user