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:
@@ -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 1–8000 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",
|
||||
|
||||
Reference in New Issue
Block a user