LIVE 模式改為雙向對話(codex exec --json + resume 接力)

摘要:
與 claude-office 同功能:側邊面板聊天室,每則訊息 spawn
codex exec --json,捕捉 thread_id 後下一則以 codex exec resume 接力。

根本原因:
單向 /task 看不到回覆也無法追問。

影響:
watcher 新增 POST /chat 與 chat_* SSE 事件;已實測一輪對話
(thread.started → agent_message → turn.completed)正確回傳。

修法:
- watch.mjs:launchChat 解析 --json 事件流(thread_id / agent_message /
  turn.completed),chatThreads 記憶 thread
- 前端與 claude-office 同步(ChatPanel / chats store / chat_* 對映)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 14:59:24 +08:00
co-authored by Claude Fable 5
parent ef07f4b8ad
commit 89a8b75b61
6 changed files with 817 additions and 399 deletions
+74 -43
View File
@@ -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<HTMLDivElement>(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 (
<div className="task-composer">
<div className="chat-panel">
<div className="chat-head">
<h3></h3>
{started && (
<button className="link-btn" onClick={() => resetChat(agentId)} disabled={busy}>
🔄
</button>
)}
</div>
{started && (
<div className="chat-list" ref={listRef}>
{messages.map((m, i) => (
<div key={i} className={`chat-msg ${m.role}`}>
{m.text}
</div>
))}
{busy && <div className="chat-msg pending"> ,</div>}
</div>
)}
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="輸入要派發的任務,將以 headless session 在該專案執行…"
rows={3}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={started ? "繼續對話…(Enter 送出)" : "跟這個專案的 Codex 對話…(Enter 送出)"}
rows={2}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) send();
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}}
/>
<div className="task-composer-row">
<label
className="tip"
data-tip="延續該專案最近一段「已結束」的 session 繼續對話(codex exec resume)。注意:無法插入正在終端機進行中的互動對話;若該專案已有任務在跑,新任務會自動排隊。"
>
<input type="checkbox" checked={resume} onChange={(e) => setResume(e.target.checked)} />
session
</label>
<span
className="tip"
data-tip="在該專案目錄開一個 headless session 執行此任務,過程會即時顯示在辦公室,完成後於事件紀錄回報。"
>
<button className="btn assign-btn" onClick={send} disabled={sending || !prompt.trim()}>
{sending ? "派發中…" : "🚀 派發任務"}
</button>
</span>
{!started ? (
<label
className="tip"
data-tip="第一句話從該專案最近一段「已結束」的 session 接續(codex exec resume)。注意:無法插入正在終端機進行中的互動對話。"
>
<input
type="checkbox"
checked={fromProject}
onChange={(e) => setFromProject(e.target.checked)}
/>
session
</label>
) : (
<span
className="chat-hint tip"
data-tip="辦公室對話有自己的 session,每則訊息自動以 --resume 延續,每則訊息逐則接力。"
>
</span>
)}
<button className="btn assign-btn" onClick={send} disabled={busy || !input.trim()}>
{busy ? "執行中…" : "送出"}
</button>
</div>
</div>
);
@@ -155,7 +186,7 @@ export function SidePanel() {
</button>
)}
{agent.role !== "subagent" && mode === "live" && <TaskComposer agentId={agent.id} />}
{agent.role !== "subagent" && mode === "live" && <ChatPanel agentId={agent.id} />}
</div>
) : (
<div className="panel-hint">
+81
View File
@@ -160,6 +160,40 @@ function onEvent(e: MessageEvent) {
bump(id);
break;
}
case "chat_start": {
if (!S().agents.has(id)) break;
S().setStatus(id, "thinking");
bump(id);
break;
}
case "chat_text": {
if (!S().agents.has(id)) break;
S().chatAppend(id, { role: "assistant", text: ev.text as string });
S().setStatus(id, "speaking");
S().setSpeech(id, ev.text as string);
bump(id);
break;
}
case "chat_done": {
if (!S().agents.has(id)) break;
S().chatSetBusy(id, false);
const thread = S().chats.get(id);
const lastAssistant = [...(thread?.messages ?? [])].reverse().find((m) => m.role === "assistant");
const finalText = (ev.text as string) ?? "";
if (ev.ok) {
// stream-json 的 result 可能與最後一則 assistant 相同,避免重複
if (finalText && finalText !== lastAssistant?.text) {
S().chatAppend(id, { role: "assistant", text: finalText });
S().setSpeech(id, finalText);
S().setStatus(id, "speaking");
}
} else {
S().chatAppend(id, { role: "system", text: `執行失敗:${(ev.error as string) ?? "未知錯誤"}` });
S().setStatus(id, "error");
}
bump(id);
break;
}
}
}
@@ -182,6 +216,53 @@ export async function dispatchTask(
}
}
/** Send a chat message to the project's office thread (auto-resumed server-side). */
export async function sendChat(
agentId: string,
message: string,
fromProject: boolean,
): Promise<boolean> {
const slug = agentId.replace(/^proj-/, "");
S().chatAppend(agentId, { role: "user", text: message });
S().chatSetBusy(agentId, true);
try {
const res = await fetch(`${WATCH_ORIGIN}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, message, fromProject }),
});
const data = (await res.json()) as { ok: boolean; error?: string };
if (!data.ok) {
S().chatSetBusy(agentId, false);
S().chatAppend(agentId, {
role: "system",
text: data.error === "busy" ? "上一則還在執行中,請稍候" : `送出失敗:${data.error ?? "未知錯誤"}`,
});
return false;
}
return true;
} catch {
S().chatSetBusy(agentId, false);
S().chatAppend(agentId, { role: "system", text: "無法連線監看伺服器 (npm run watch)" });
return false;
}
}
/** Reset the office chat thread (kills any in-flight run server-side). */
export async function resetChat(agentId: string): Promise<void> {
const slug = agentId.replace(/^proj-/, "");
S().chatClear(agentId);
try {
await fetch(`${WATCH_ORIGIN}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, reset: true }),
});
} catch {
/* offline — local clear is enough */
}
}
/** Fade statuses back to idle / offline when a project goes quiet. */
function decayPass() {
const now = Date.now();
+402 -355
View File
@@ -1,355 +1,402 @@
import { create } from "zustand";
import { DESK_SLOTS, HOT_DESK_SLOTS, meetingSeats } from "@/lib/positions";
import { interpolatePath, planWalkPath, walkDuration } from "@/lib/movement";
import { ENTRANCE, MEETING_CENTER } from "@/lib/constants";
import type {
AgentStatus,
AgentZone,
CollaborationLink,
Meeting,
OfficeEvent,
Point,
ToolCall,
VisualAgent,
} from "@/lib/types";
interface OfficeState {
agents: Map<string, VisualAgent>;
links: CollaborationLink[];
meetings: Meeting[];
events: OfficeEvent[];
selectedAgentId: string | null;
theme: "light" | "dark";
paused: boolean;
speed: number;
/** data source: scripted simulation or live Claude Code transcripts */
mode: "sim" | "live";
/** side panel visibility (collapse to maximise the office view) */
panelOpen: boolean;
/** simulation clock, seconds */
clock: number;
selectAgent: (id: string | null) => void;
setTheme: (t: "light" | "dark") => void;
setPaused: (p: boolean) => void;
setSpeed: (s: number) => void;
setMode: (m: "sim" | "live") => void;
togglePanel: () => void;
clearWorld: () => void;
addEvent: (icon: string, text: string) => void;
spawnAgent: (a: {
id: string;
name: string;
role: VisualAgent["role"];
model: string;
homeSlot: number;
parentId?: string;
}) => void;
removeAgent: (id: string) => void;
setStatus: (id: string, status: AgentStatus) => void;
setTool: (id: string, tool: ToolCall | null) => void;
setSpeech: (id: string, text: string | null) => void;
startWalk: (
id: string,
to: Point,
toZone: AgentZone,
opts?: { arriveStatus?: AgentStatus; despawnOnArrive?: boolean },
) => void;
walkHome: (id: string, arriveStatus?: AgentStatus) => void;
addLink: (link: CollaborationLink) => void;
removeLink: (sourceId: string, targetId: string) => void;
startMeeting: (id: string, agentIds: string[]) => void;
endMeeting: (id: string) => void;
/** advance movements + clock; called once per frame with scaled dt */
tick: (dt: number) => void;
}
let eventSeq = 0;
/* UI preferences persisted across reloads. */
function loadPref(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function savePref(key: string, value: string) {
try {
localStorage.setItem(key, value);
} catch {
/* ignore */
}
}
const savedTheme = loadPref("office-theme");
const savedPanel = loadPref("office-panel");
/** Read the preferred data source ("live" | "sim"); the runtime applies it on boot. */
export function loadPreferredMode(): "sim" | "live" {
return loadPref("office-mode") === "live" ? "live" : "sim";
}
export const useOfficeStore = create<OfficeState>()((set, get) => ({
agents: new Map(),
links: [],
meetings: [],
events: [],
selectedAgentId: null,
theme: savedTheme === "dark" ? "dark" : "light",
paused: false,
speed: 1,
mode: "sim",
panelOpen: savedPanel !== "0",
clock: 0,
selectAgent: (id) => set({ selectedAgentId: id }),
setTheme: (t) => {
savePref("office-theme", t);
set({ theme: t });
},
setPaused: (p) => set({ paused: p }),
setSpeed: (s) => set({ speed: s }),
setMode: (m) => {
savePref("office-mode", m);
set({ mode: m });
},
togglePanel: () =>
set((state) => {
savePref("office-panel", state.panelOpen ? "0" : "1");
return { panelOpen: !state.panelOpen };
}),
clearWorld: () =>
set({ agents: new Map(), links: [], meetings: [], events: [], selectedAgentId: null }),
addEvent: (icon, text) =>
set((state) => ({
events: [{ at: eventSeq++, text, icon }, ...state.events].slice(0, 60),
})),
spawnAgent: ({ id, name, role, model, homeSlot, parentId }) =>
set((state) => {
const isSub = role === "subagent";
const slot = isSub ? HOT_DESK_SLOTS[homeSlot] : DESK_SLOTS[homeSlot];
const agent: VisualAgent = {
id,
name,
role,
model,
status: "spawning",
position: { ...ENTRANCE },
zone: "corridor",
homePosition: { x: slot.x, y: slot.y },
homeZone: isSub ? "hotDesk" : "desk",
homeSlot,
currentTool: null,
speech: null,
movement: null,
parentId: parentId ?? null,
childIds: [],
toolCallCount: 0,
toolHistory: [],
spawnedAt: state.clock,
};
const agents = new Map(state.agents);
agents.set(id, agent);
if (parentId) {
const parent = agents.get(parentId);
if (parent) agents.set(parentId, { ...parent, childIds: [...parent.childIds, id] });
}
return { agents };
}),
removeAgent: (id) =>
set((state) => {
const agents = new Map(state.agents);
const agent = agents.get(id);
agents.delete(id);
if (agent?.parentId) {
const parent = agents.get(agent.parentId);
if (parent) {
agents.set(agent.parentId, {
...parent,
childIds: parent.childIds.filter((c) => c !== id),
});
}
}
return {
agents,
links: state.links.filter((l) => l.sourceId !== id && l.targetId !== id),
selectedAgentId: state.selectedAgentId === id ? null : state.selectedAgentId,
};
}),
setStatus: (id, status) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent || agent.status === status) return {};
const agents = new Map(state.agents);
agents.set(id, {
...agent,
status,
speech: status === "speaking" ? agent.speech : null,
currentTool: status === "tool_calling" ? agent.currentTool : null,
});
return { agents };
}),
setTool: (id, tool) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent) return {};
const agents = new Map(state.agents);
agents.set(id, {
...agent,
currentTool: tool,
toolCallCount: tool ? agent.toolCallCount + 1 : agent.toolCallCount,
toolHistory: tool ? [tool.name, ...agent.toolHistory].slice(0, 12) : agent.toolHistory,
});
return { agents };
}),
setSpeech: (id, text) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent) return {};
const agents = new Map(state.agents);
agents.set(id, { ...agent, speech: text });
return { agents };
}),
startWalk: (id, to, toZone, opts) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent) return {};
const from = agent.movement
? interpolatePath(agent.movement.path, agent.movement.elapsed / agent.movement.duration)
: agent.position;
const path = planWalkPath(from, to, agent.zone, toZone);
const agents = new Map(state.agents);
agents.set(id, {
...agent,
position: { ...from },
movement: {
path,
duration: walkDuration(path),
elapsed: 0,
toZone,
arriveStatus: opts?.arriveStatus,
despawnOnArrive: opts?.despawnOnArrive,
},
});
return { agents };
}),
walkHome: (id, arriveStatus = "idle") => {
const agent = get().agents.get(id);
if (!agent) return;
get().startWalk(id, agent.homePosition, agent.homeZone, { arriveStatus });
},
addLink: (link) =>
set((state) => {
const others = state.links.filter(
(l) => !(l.sourceId === link.sourceId && l.targetId === link.targetId),
);
return { links: [...others, link] };
}),
removeLink: (sourceId, targetId) =>
set((state) => ({
links: state.links.filter((l) => !(l.sourceId === sourceId && l.targetId === targetId)),
})),
startMeeting: (id, agentIds) => {
set((state) => ({
meetings: [...state.meetings, { id, agentIds, center: { ...MEETING_CENTER } }],
}));
const seats = meetingSeats(agentIds.length);
agentIds.forEach((agentId, i) => {
get().startWalk(agentId, seats[i], "meeting", { arriveStatus: "idle" });
});
// pairwise meeting links
for (let i = 0; i < agentIds.length; i++) {
for (let j = i + 1; j < agentIds.length; j++) {
get().addLink({ sourceId: agentIds[i], targetId: agentIds[j], strength: 0.85, kind: "meeting" });
}
}
},
endMeeting: (id) => {
const meeting = get().meetings.find((m) => m.id === id);
if (!meeting) return;
set((state) => ({ meetings: state.meetings.filter((m) => m.id !== id) }));
const ids = meeting.agentIds;
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
get().removeLink(ids[i], ids[j]);
}
}
ids.forEach((agentId) => {
const agent = get().agents.get(agentId);
if (agent) get().walkHome(agentId);
});
},
tick: (dt) =>
set((state) => {
let agents: Map<string, VisualAgent> | null = null;
const toRemove: string[] = [];
for (const [id, agent] of state.agents) {
if (!agent.movement) continue;
if (!agents) agents = new Map(state.agents);
const elapsed = agent.movement.elapsed + dt;
if (elapsed >= agent.movement.duration) {
const end = agent.movement.path[agent.movement.path.length - 1];
if (agent.movement.despawnOnArrive) {
toRemove.push(id);
continue;
}
agents.set(id, {
...agent,
position: { ...end },
zone: agent.movement.toZone,
status: agent.movement.arriveStatus ?? agent.status,
movement: null,
});
} else {
const pos = interpolatePath(agent.movement.path, elapsed / agent.movement.duration);
agents.set(id, {
...agent,
position: pos,
movement: { ...agent.movement, elapsed },
});
}
}
if (toRemove.length > 0 && agents) {
for (const id of toRemove) agents.delete(id);
}
return {
clock: state.clock + dt,
...(agents ? { agents } : {}),
...(toRemove.length > 0
? {
links: state.links.filter(
(l) => !toRemove.includes(l.sourceId) && !toRemove.includes(l.targetId),
),
selectedAgentId:
state.selectedAgentId && toRemove.includes(state.selectedAgentId)
? null
: state.selectedAgentId,
}
: {}),
};
}),
}));
import { create } from "zustand";
import { DESK_SLOTS, HOT_DESK_SLOTS, meetingSeats } from "@/lib/positions";
import { interpolatePath, planWalkPath, walkDuration } from "@/lib/movement";
import { ENTRANCE, MEETING_CENTER } from "@/lib/constants";
import type {
AgentStatus,
AgentZone,
CollaborationLink,
Meeting,
OfficeEvent,
Point,
ToolCall,
VisualAgent,
} from "@/lib/types";
export interface ChatMessage {
role: "user" | "assistant" | "system";
text: string;
}
export interface ChatThread {
messages: ChatMessage[];
busy: boolean;
}
interface OfficeState {
agents: Map<string, VisualAgent>;
links: CollaborationLink[];
meetings: Meeting[];
events: OfficeEvent[];
/** LIVE-mode chat threads, keyed by agent id */
chats: Map<string, ChatThread>;
selectedAgentId: string | null;
theme: "light" | "dark";
paused: boolean;
speed: number;
/** data source: scripted simulation or live Claude Code transcripts */
mode: "sim" | "live";
/** side panel visibility (collapse to maximise the office view) */
panelOpen: boolean;
/** simulation clock, seconds */
clock: number;
selectAgent: (id: string | null) => void;
setTheme: (t: "light" | "dark") => void;
setPaused: (p: boolean) => void;
setSpeed: (s: number) => void;
setMode: (m: "sim" | "live") => void;
togglePanel: () => void;
clearWorld: () => void;
chatAppend: (agentId: string, msg: ChatMessage) => void;
chatSetBusy: (agentId: string, busy: boolean) => void;
chatClear: (agentId: string) => void;
addEvent: (icon: string, text: string) => void;
spawnAgent: (a: {
id: string;
name: string;
role: VisualAgent["role"];
model: string;
homeSlot: number;
parentId?: string;
}) => void;
removeAgent: (id: string) => void;
setStatus: (id: string, status: AgentStatus) => void;
setTool: (id: string, tool: ToolCall | null) => void;
setSpeech: (id: string, text: string | null) => void;
startWalk: (
id: string,
to: Point,
toZone: AgentZone,
opts?: { arriveStatus?: AgentStatus; despawnOnArrive?: boolean },
) => void;
walkHome: (id: string, arriveStatus?: AgentStatus) => void;
addLink: (link: CollaborationLink) => void;
removeLink: (sourceId: string, targetId: string) => void;
startMeeting: (id: string, agentIds: string[]) => void;
endMeeting: (id: string) => void;
/** advance movements + clock; called once per frame with scaled dt */
tick: (dt: number) => void;
}
let eventSeq = 0;
/* UI preferences persisted across reloads. */
function loadPref(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function savePref(key: string, value: string) {
try {
localStorage.setItem(key, value);
} catch {
/* ignore */
}
}
const savedTheme = loadPref("office-theme");
const savedPanel = loadPref("office-panel");
/** Read the preferred data source ("live" | "sim"); the runtime applies it on boot. */
export function loadPreferredMode(): "sim" | "live" {
return loadPref("office-mode") === "live" ? "live" : "sim";
}
export const useOfficeStore = create<OfficeState>()((set, get) => ({
agents: new Map(),
links: [],
meetings: [],
events: [],
chats: new Map(),
selectedAgentId: null,
theme: savedTheme === "dark" ? "dark" : "light",
paused: false,
speed: 1,
mode: "sim",
panelOpen: savedPanel !== "0",
clock: 0,
selectAgent: (id) => set({ selectedAgentId: id }),
setTheme: (t) => {
savePref("office-theme", t);
set({ theme: t });
},
setPaused: (p) => set({ paused: p }),
setSpeed: (s) => set({ speed: s }),
setMode: (m) => {
savePref("office-mode", m);
set({ mode: m });
},
togglePanel: () =>
set((state) => {
savePref("office-panel", state.panelOpen ? "0" : "1");
return { panelOpen: !state.panelOpen };
}),
clearWorld: () =>
set({
agents: new Map(),
links: [],
meetings: [],
events: [],
chats: new Map(),
selectedAgentId: null,
}),
chatAppend: (agentId, msg) =>
set((state) => {
const chats = new Map(state.chats);
const thread = chats.get(agentId) ?? { messages: [], busy: false };
chats.set(agentId, { ...thread, messages: [...thread.messages, msg].slice(-100) });
return { chats };
}),
chatSetBusy: (agentId, busy) =>
set((state) => {
const chats = new Map(state.chats);
const thread = chats.get(agentId) ?? { messages: [], busy: false };
chats.set(agentId, { ...thread, busy });
return { chats };
}),
chatClear: (agentId) =>
set((state) => {
const chats = new Map(state.chats);
chats.delete(agentId);
return { chats };
}),
addEvent: (icon, text) =>
set((state) => ({
events: [{ at: eventSeq++, text, icon }, ...state.events].slice(0, 60),
})),
spawnAgent: ({ id, name, role, model, homeSlot, parentId }) =>
set((state) => {
const isSub = role === "subagent";
const slot = isSub ? HOT_DESK_SLOTS[homeSlot] : DESK_SLOTS[homeSlot];
const agent: VisualAgent = {
id,
name,
role,
model,
status: "spawning",
position: { ...ENTRANCE },
zone: "corridor",
homePosition: { x: slot.x, y: slot.y },
homeZone: isSub ? "hotDesk" : "desk",
homeSlot,
currentTool: null,
speech: null,
movement: null,
parentId: parentId ?? null,
childIds: [],
toolCallCount: 0,
toolHistory: [],
spawnedAt: state.clock,
};
const agents = new Map(state.agents);
agents.set(id, agent);
if (parentId) {
const parent = agents.get(parentId);
if (parent) agents.set(parentId, { ...parent, childIds: [...parent.childIds, id] });
}
return { agents };
}),
removeAgent: (id) =>
set((state) => {
const agents = new Map(state.agents);
const agent = agents.get(id);
agents.delete(id);
if (agent?.parentId) {
const parent = agents.get(agent.parentId);
if (parent) {
agents.set(agent.parentId, {
...parent,
childIds: parent.childIds.filter((c) => c !== id),
});
}
}
return {
agents,
links: state.links.filter((l) => l.sourceId !== id && l.targetId !== id),
selectedAgentId: state.selectedAgentId === id ? null : state.selectedAgentId,
};
}),
setStatus: (id, status) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent || agent.status === status) return {};
const agents = new Map(state.agents);
agents.set(id, {
...agent,
status,
speech: status === "speaking" ? agent.speech : null,
currentTool: status === "tool_calling" ? agent.currentTool : null,
});
return { agents };
}),
setTool: (id, tool) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent) return {};
const agents = new Map(state.agents);
agents.set(id, {
...agent,
currentTool: tool,
toolCallCount: tool ? agent.toolCallCount + 1 : agent.toolCallCount,
toolHistory: tool ? [tool.name, ...agent.toolHistory].slice(0, 12) : agent.toolHistory,
});
return { agents };
}),
setSpeech: (id, text) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent) return {};
const agents = new Map(state.agents);
agents.set(id, { ...agent, speech: text });
return { agents };
}),
startWalk: (id, to, toZone, opts) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent) return {};
const from = agent.movement
? interpolatePath(agent.movement.path, agent.movement.elapsed / agent.movement.duration)
: agent.position;
const path = planWalkPath(from, to, agent.zone, toZone);
const agents = new Map(state.agents);
agents.set(id, {
...agent,
position: { ...from },
movement: {
path,
duration: walkDuration(path),
elapsed: 0,
toZone,
arriveStatus: opts?.arriveStatus,
despawnOnArrive: opts?.despawnOnArrive,
},
});
return { agents };
}),
walkHome: (id, arriveStatus = "idle") => {
const agent = get().agents.get(id);
if (!agent) return;
get().startWalk(id, agent.homePosition, agent.homeZone, { arriveStatus });
},
addLink: (link) =>
set((state) => {
const others = state.links.filter(
(l) => !(l.sourceId === link.sourceId && l.targetId === link.targetId),
);
return { links: [...others, link] };
}),
removeLink: (sourceId, targetId) =>
set((state) => ({
links: state.links.filter((l) => !(l.sourceId === sourceId && l.targetId === targetId)),
})),
startMeeting: (id, agentIds) => {
set((state) => ({
meetings: [...state.meetings, { id, agentIds, center: { ...MEETING_CENTER } }],
}));
const seats = meetingSeats(agentIds.length);
agentIds.forEach((agentId, i) => {
get().startWalk(agentId, seats[i], "meeting", { arriveStatus: "idle" });
});
// pairwise meeting links
for (let i = 0; i < agentIds.length; i++) {
for (let j = i + 1; j < agentIds.length; j++) {
get().addLink({ sourceId: agentIds[i], targetId: agentIds[j], strength: 0.85, kind: "meeting" });
}
}
},
endMeeting: (id) => {
const meeting = get().meetings.find((m) => m.id === id);
if (!meeting) return;
set((state) => ({ meetings: state.meetings.filter((m) => m.id !== id) }));
const ids = meeting.agentIds;
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
get().removeLink(ids[i], ids[j]);
}
}
ids.forEach((agentId) => {
const agent = get().agents.get(agentId);
if (agent) get().walkHome(agentId);
});
},
tick: (dt) =>
set((state) => {
let agents: Map<string, VisualAgent> | null = null;
const toRemove: string[] = [];
for (const [id, agent] of state.agents) {
if (!agent.movement) continue;
if (!agents) agents = new Map(state.agents);
const elapsed = agent.movement.elapsed + dt;
if (elapsed >= agent.movement.duration) {
const end = agent.movement.path[agent.movement.path.length - 1];
if (agent.movement.despawnOnArrive) {
toRemove.push(id);
continue;
}
agents.set(id, {
...agent,
position: { ...end },
zone: agent.movement.toZone,
status: agent.movement.arriveStatus ?? agent.status,
movement: null,
});
} else {
const pos = interpolatePath(agent.movement.path, elapsed / agent.movement.duration);
agents.set(id, {
...agent,
position: pos,
movement: { ...agent.movement, elapsed },
});
}
}
if (toRemove.length > 0 && agents) {
for (const id of toRemove) agents.delete(id);
}
return {
clock: state.clock + dt,
...(agents ? { agents } : {}),
...(toRemove.length > 0
? {
links: state.links.filter(
(l) => !toRemove.includes(l.sourceId) && !toRemove.includes(l.targetId),
),
selectedAgentId:
state.selectedAgentId && toRemove.includes(state.selectedAgentId)
? null
: state.selectedAgentId,
}
: {}),
};
}),
}));
+102
View File
@@ -348,6 +348,108 @@
border-color: transparent;
}
.chat-panel {
margin-top: 14px;
}
.chat-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.chat-head h3 {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
.chat-head .link-btn:disabled {
opacity: 0.4;
cursor: default;
}
.chat-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 260px;
overflow-y: auto;
padding: 8px;
margin-bottom: 8px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
}
.chat-msg {
max-width: 88%;
font-size: 12px;
line-height: 1.5;
padding: 6px 10px;
border-radius: 10px;
white-space: pre-wrap;
overflow-wrap: anywhere;
animation: card-in 0.2s ease-out;
}
.chat-msg.user {
align-self: flex-end;
background: var(--terracotta);
color: #fff;
border-bottom-right-radius: 3px;
}
.chat-msg.assistant {
align-self: flex-start;
background: var(--bg-panel);
border: 1px solid var(--border);
border-bottom-left-radius: 3px;
}
.chat-msg.system {
align-self: center;
color: var(--text-dim);
font-size: 11px;
font-style: italic;
background: none;
padding: 2px 6px;
}
.chat-msg.pending {
align-self: flex-start;
color: var(--text-dim);
font-size: 11px;
background: none;
animation: emote-bob 1.6s ease-in-out infinite;
}
.chat-panel textarea {
width: 100%;
resize: vertical;
font-family: inherit;
font-size: 12px;
line-height: 1.5;
color: var(--text);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
}
.chat-panel textarea:focus {
outline: none;
border-color: var(--terracotta);
}
.chat-hint {
font-size: 11px;
color: var(--text-dim);
}
.task-composer {
margin-top: 14px;
}