diff --git a/README.md b/README.md index 2223a08..853c21c 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,17 @@ npm run watch # http://localhost:5181/events (SSE) 注意:監看伺服器會讀取本機的 Codex 對話記錄,**只綁 127.0.0.1、不要對外開放**。 -### 在辦公室下任務(LIVE 模式) +### 在辦公室對話(LIVE 模式) + +點選專案 agent 後,側邊面板是一個**雙向聊天室**:每則訊息 spawn 一次 +`codex exec --json`,從輸出捕捉 `thread_id`,下一則自動 `codex exec resume` +接力。回覆同時顯示在聊天串和小人頭上的對話氣泡。 + +- 「接續專案最近 session」:第一句改從該專案最近的 rollout 接續 +- 「🔄 新對話」:清空 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 8303af4..9423795 100644 --- a/server/watch.mjs +++ b/server/watch.mjs @@ -294,6 +294,149 @@ function launchTask(slug, prompt, resume) { }); } +/* ── two-way chat: one office thread per project, stitched with `codex exec resume` ── */ + +/** slug → { threadId, child } */ +const chatThreads = new Map(); + +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 = { threadId: null, child: null }; + chatThreads.set(slug, thread); + } + if (thread.child) return { ok: false, error: "busy" }; + + let resumeId = thread.threadId; + if (!resumeId && fromProject) { + const sid = newestSessionId(slug); + if (sid) resumeId = sid; + } + + const args = ["exec"]; + if (resumeId) args.push("resume", resumeId); + args.push("--json", "--full-auto", "--skip-git-repo-check", "-"); + + // Message goes through stdin ("-") — never through shell arguments. + const child = spawn("codex", args, { + cwd: proj.cwd, + shell: true, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + thread.child = child; + + let stderrTail = ""; + let stdoutRemainder = ""; + let sawDone = false; + let lastText = ""; + + 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 (typeof o.thread_id === "string") thread.threadId = o.thread_id; + if (o.type === "thread.started") { + broadcast({ type: "chat_start", slug }); + } else if (o.type === "item.completed" && o.item?.type === "agent_message" && o.item.text) { + lastText = String(o.item.text); + broadcast({ type: "chat_text", slug, text: lastText }); + } else if (o.type === "turn.completed") { + sawDone = true; + broadcast({ type: "chat_done", slug, ok: true, text: lastText }); + } else if (o.type === "turn.failed" || o.type === "error") { + sawDone = true; + broadcast({ + type: "chat_done", + slug, + ok: false, + text: "", + error: String(o.error?.message ?? o.message ?? "turn failed").slice(0, 200), + }); + } + } + }); + + 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 (!sawDone) { + broadcast({ + type: "chat_done", + slug, + ok: code === 0 && Boolean(lastText), + text: lastText, + error: code === 0 ? undefined : 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 = 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)) { @@ -380,6 +523,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 a099c46..773acdf 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 &&
⋯ 執行中,過程看辦公室裡的小人
} +
+ )} +