diff --git a/README.md b/README.md index 2aea61d..a53e54a 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,19 @@ session transcript(JSONL),只讀取新增的位元組,轉譯成事件推播給 注意:監看伺服器會讀取本機的 Claude Code 對話記錄(含程式碼與提示詞), **只綁 127.0.0.1、不要對外開放**;如需團隊共用請自行加上驗證。 -### 在辦公室下任務(LIVE 模式) +### 在辦公室對話(LIVE 模式) + +點選專案 agent 後,側邊面板是一個**雙向聊天室**(參考 claudecodeui 的做法): +每則訊息 spawn 一次 `claude -p --output-format stream-json`,從輸出捕捉 +`session_id`,下一則自動 `--resume` 接力 — 對話因此能連續。回覆會同時 +顯示在聊天串和小人頭上的對話氣泡;執行過程(思考/工具)即時演在辦公室。 + +- 「接續專案最近 session」:第一句改從該專案最近一段已結束的 session 接續 +- 「🔄 新對話」:清空 office 對話串(會終止進行中的執行) +- 事件:`POST /chat` → SSE 廣播 `chat_start` / `chat_text` / `chat_done` +- 同專案一次只跑一則;仍無法插入終端機正在進行中的互動對話 + +### 在辦公室下任務(API) 點選任一專案 agent,側邊面板會出現任務輸入框 —「🚀 派發任務」會透過 `POST /task` 讓監看伺服器在該專案目錄 spawn 一個 headless session: diff --git a/server/watch.mjs b/server/watch.mjs index 9f1f8fa..956fd8f 100644 --- a/server/watch.mjs +++ b/server/watch.mjs @@ -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", diff --git a/src/components/SidePanel.tsx b/src/components/SidePanel.tsx index 30e0e6d..527a824 100644 --- a/src/components/SidePanel.tsx +++ b/src/components/SidePanel.tsx @@ -1,62 +1,93 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { STATUS_COLORS, STATUS_LABELS } from "@/lib/constants"; import { generateAppearance } from "@/lib/appearance"; import { useOfficeStore } from "@/store/office-store"; import { getDirector } from "@/sim/runtime"; -import { dispatchTask } from "@/gateway/live"; +import { resetChat, sendChat } from "@/gateway/live"; import { Pawn } from "./Pawn"; const ROLE_LABELS = { lead: "Lead Agent", agent: "Agent", subagent: "Subagent" } as const; -/** LIVE mode: dispatch a real headless task to this project via the watcher. */ -function TaskComposer({ agentId }: { agentId: string }) { - const [prompt, setPrompt] = useState(""); - const [resume, setResume] = useState(false); - const [sending, setSending] = useState(false); +/** LIVE mode: two-way chat with this project — each message runs headless, stitched with --resume. */ +function ChatPanel({ agentId }: { agentId: string }) { + const thread = useOfficeStore((s) => s.chats.get(agentId)); + const [input, setInput] = useState(""); + const [fromProject, setFromProject] = useState(false); + const listRef = useRef(null); - const send = async () => { - const text = prompt.trim(); - if (!text || sending) return; - setSending(true); - const result = await dispatchTask(agentId, text, resume); - setSending(false); - if (result.ok) { - setPrompt(""); - if (result.queued) { - useOfficeStore.getState().addEvent("⏳", `任務已排隊(第 ${result.queued} 位)`); - } - } else { - useOfficeStore.getState().addEvent("⚠️", `派發失敗:${result.error ?? "未知錯誤"}`); - } + const busy = thread?.busy ?? false; + const messages = thread?.messages ?? []; + const started = messages.length > 0; + + useEffect(() => { + listRef.current?.scrollTo({ top: listRef.current.scrollHeight }); + }, [messages.length, busy]); + + const send = () => { + const text = input.trim(); + if (!text || busy) return; + sendChat(agentId, text, fromProject); + setInput(""); }; return ( -
+
+
+

對話

+ {started && ( + + )} +
+ + {started && ( +
+ {messages.map((m, i) => ( +
+ {m.text} +
+ ))} + {busy &&
⋯ 執行中,過程看辦公室裡的小人
} +
+ )} +