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();
|
||||
}
|
||||
});
|
||||
|
||||
49
my-deepagent/static/conversation.html
Normal file
49
my-deepagent/static/conversation.html
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user