摘要: 與 claude-office 同步:深色模式、LIVE 模式與面板收合狀態 存入 localStorage,載入時自動套用。 根本原因: 偏好只存在記憶體 state,重整即重置。 影響: 使用者偏好跨重整保留;localStorage 不可用時安全降級。 修法: 同步 office-store.ts(loadPref/savePref)與 App.tsx(開機套用 mode)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
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,
|
|
}
|
|
: {}),
|
|
};
|
|
}),
|
|
}));
|