LIVE 模式改為雙向對話,參考 claudecodeui 的 resume 接力機制

摘要:
側邊面板從單向任務派發升級為聊天室:每則訊息 spawn
claude -p --output-format stream-json,捕捉 session_id 後
下一則自動 --resume 接力,對話得以連續;回覆同步顯示在
聊天串與小人頭上的對話氣泡。

根本原因:
單向 /task 發出後看不到回覆內容,也無法追問;claudecodeui
證明 headless + resume 接力即可實現連續對話,無需常駐 runtime。

影響:
watcher 新增 POST /chat(每專案一個 office thread、busy 檢查、
reset 會終止進行中的執行);SSE 新增 chat_start/chat_text/chat_done。
/task API 保留。已實測兩輪對話,第二輪正確記得第一輪內容。

修法:
- watch.mjs:launchChat 解析 stream-json(init 抓 session_id、
  assistant 文字轉發、result 收尾),chatThreads 記憶 thread
- store:chats Map + chatAppend/chatSetBusy/chatClear
- live.ts:sendChat/resetChat + chat_* 事件對映
- SidePanel:ChatPanel(氣泡串、自動捲底、Enter 送出、新對話)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 14:59:19 +08:00
co-authored by Claude Fable 5
parent a967306a2f
commit b12627a9ef
6 changed files with 470 additions and 45 deletions
+152
View File
@@ -312,6 +312,154 @@ async function launchTask(slug, prompt, resume) {
});
}
/* ── two-way chat: one office thread per project, stitched with --resume ── */
/** slug → { sessionId, child } */
const chatThreads = new Map();
async function launchChat(slug, message, fromProject) {
const proj = projects.get(slug);
if (!proj?.cwd) return { ok: false, error: "unknown project" };
let thread = chatThreads.get(slug);
if (!thread) {
thread = { sessionId: null, child: null };
chatThreads.set(slug, thread);
}
if (thread.child) return { ok: false, error: "busy" };
let resumeId = thread.sessionId;
if (!resumeId && fromProject) {
const sid = await newestSessionId(slug);
if (sid && /^[0-9a-fA-F-]+$/.test(sid)) resumeId = sid;
}
const args = [
"-p",
"--output-format",
"stream-json",
"--verbose",
"--permission-mode",
PERMISSION_MODE,
];
if (resumeId) args.push("--resume", resumeId);
// Message goes through stdin — never through shell arguments.
const child = spawn("claude", args, {
cwd: proj.cwd,
shell: true,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
thread.child = child;
let stderrTail = "";
let stdoutRemainder = "";
let sawResult = false;
child.stderr.on("data", (c) => {
stderrTail = (stderrTail + c.toString("utf8")).slice(-400);
});
child.stdout.on("data", (c) => {
const chunk = stdoutRemainder + c.toString("utf8");
const lines = chunk.split("\n");
stdoutRemainder = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
let o;
try {
o = JSON.parse(line);
} catch {
continue;
}
if (o.session_id) thread.sessionId = o.session_id;
if (o.type === "system" && o.subtype === "init") {
broadcast({ type: "chat_start", slug });
} else if (o.type === "assistant") {
const text = (o.message?.content ?? [])
.filter((b) => b && b.type === "text" && b.text)
.map((b) => b.text)
.join("\n")
.trim();
if (text) broadcast({ type: "chat_text", slug, text });
} else if (o.type === "result") {
sawResult = true;
broadcast({
type: "chat_done",
slug,
ok: o.subtype === "success",
text: typeof o.result === "string" ? o.result : "",
});
}
}
});
child.stdin.write(message);
child.stdin.end();
const killer = setTimeout(() => child.kill(), TASK_TIMEOUT_MS);
child.on("close", (code) => {
clearTimeout(killer);
thread.child = null;
if (!sawResult) {
broadcast({
type: "chat_done",
slug,
ok: false,
text: "",
error: stderrTail.trim().slice(-200) || `exit ${code}`,
});
}
});
child.on("error", (err) => {
clearTimeout(killer);
thread.child = null;
broadcast({ type: "chat_done", slug, ok: false, text: "", error: String(err).slice(0, 200) });
});
return { ok: true, resumed: Boolean(resumeId) };
}
async function handleChatRequest(req, res) {
const cors = corsFor(req);
if (req.method === "OPTIONS") {
res.writeHead(Object.keys(cors).length ? 204 : 403, cors);
res.end();
return;
}
if (!Object.keys(cors).length) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "origin not allowed" }));
return;
}
const reply = (status, payload) => {
res.writeHead(status, { ...cors, "Content-Type": "application/json" });
res.end(JSON.stringify(payload));
};
try {
const body = JSON.parse(await readBody(req));
const slug = String(body.slug ?? "");
if (!projects.has(slug)) return reply(404, { ok: false, error: "unknown project" });
if (body.reset) {
const thread = chatThreads.get(slug);
if (thread?.child) thread.child.kill();
chatThreads.delete(slug);
return reply(200, { ok: true });
}
const message = String(body.message ?? "").trim();
if (!message || message.length > 8000) {
return reply(400, { ok: false, error: "message must be 18000 chars" });
}
const result = await launchChat(slug, message, Boolean(body.fromProject));
reply(result.ok ? 200 : 409, result);
} catch {
reply(400, { ok: false, error: "bad request" });
}
}
function corsFor(req) {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
@@ -403,6 +551,10 @@ const server = createServer((req, res) => {
handleTaskRequest(req, res);
return;
}
if (req.url === "/chat") {
handleChatRequest(req, res);
return;
}
if (req.url === "/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",