import { create } from 'zustand' import type { Room, Message, AgentInfo, SkillMeta, WsEvent, TodoItem } from './types' interface TokenUsage { promptTokens: number completionTokens: number totalTokens: number } interface AppState { theme: 'light' | 'dark' user: { name: string avatar_color: string } | null onboardingCompleted: boolean rooms: Room[] activeRoomId: string | null messages: Record tasks: Record workspace: Record agents: AgentInfo[] skills: SkillMeta[] page: 'chat' | 'agents' | 'skills' | 'market' | 'settings' ws: Record tokenUsage: Record todoItems: Record fileReaders: Record> workingFiles: Record workStartedAt: Record // roomId -> timestamp (ms) setTheme: (theme: 'light' | 'dark') => void toggleTheme: () => void setOnboardingCompleted: (completed: boolean) => void fetchUser: () => Promise setPage: (p: AppState['page']) => void setActiveRoom: (id: string) => void fetchRooms: () => Promise fetchAgents: () => Promise fetchSkills: () => Promise sendMessage: (roomId: string, content: string) => void connectRoom: (roomId: string) => void markFileRead: (roomId: string, filename: string) => void setRoomTeam: (roomId: string, team: string) => Promise } const API = '/api' export const useStore = create((set, get) => { const getInitialTheme = (): 'light' | 'dark' => { if (typeof window !== 'undefined') { const saved = localStorage.getItem('theme') if (saved === 'light' || saved === 'dark') return saved return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } return 'dark' } const applyTheme = (theme: 'light' | 'dark') => { if (typeof window !== 'undefined') { document.documentElement.classList.remove('light', 'dark') document.documentElement.classList.add(theme) localStorage.setItem('theme', theme) } } const initialTheme = getInitialTheme() applyTheme(initialTheme) return { theme: initialTheme, user: null, onboardingCompleted: typeof window !== 'undefined' ? localStorage.getItem('onboarding_completed') === 'true' : false, rooms: [], activeRoomId: typeof window !== 'undefined' ? localStorage.getItem('activeRoomId') : null, messages: {}, tasks: {}, workspace: {}, agents: [], skills: [], page: (typeof window !== 'undefined' ? localStorage.getItem('page') as AppState['page'] : null) || 'chat', ws: {}, tokenUsage: {}, todoItems: {}, fileReaders: {}, workingFiles: {}, workStartedAt: {}, setTheme: (theme) => { applyTheme(theme) set({ theme }) }, toggleTheme: () => { const newTheme = get().theme === 'dark' ? 'light' : 'dark' applyTheme(newTheme) set({ theme: newTheme }) }, setOnboardingCompleted: (completed) => { localStorage.setItem('onboarding_completed', completed ? 'true' : 'false') set({ onboardingCompleted: completed }) }, fetchUser: async () => { try { const res = await fetch(`${API}/user`) const data = await res.json() set({ user: { name: data.name || '用户', avatar_color: data.avatar_color || '#5865F2' } }) } catch { set({ user: { name: '用户', avatar_color: '#5865F2' } }) } }, setPage: (page) => { localStorage.setItem('page', page) set({ page }) }, setActiveRoom: (id) => { localStorage.setItem('activeRoomId', id) set({ activeRoomId: id }) get().connectRoom(id) fetch(`${API}/rooms/${id}/tasks`).then(r => r.json()).then(d => { set(s => ({ tasks: { ...s.tasks, [id]: d.content } })) }) fetch(`${API}/rooms/${id}/workspace`).then(r => r.json()).then(files => { set(s => ({ workspace: { ...s.workspace, [id]: files || [] } })) }) fetch(`${API}/rooms/${id}/messages`).then(r => r.json()).then(msgs => { if (msgs?.length) set(s => ({ messages: { ...s.messages, [id]: msgs } })) }).catch(() => {}) fetch(`${API}/rooms/${id}/token-usage`).then(r => r.json()).then(data => { if (data) set(s => ({ tokenUsage: { ...s.tokenUsage, [id]: { promptTokens: data.prompt_tokens || 0, completionTokens: data.completion_tokens || 0, totalTokens: data.total_tokens || 0, }} })) }).catch(() => {}) }, fetchRooms: async () => { const rooms = await fetch(`${API}/rooms`).then(r => r.json()) set({ rooms: rooms || [] }) }, fetchAgents: async () => { const agents = await fetch(`${API}/agents`).then(r => r.json()) set({ agents: agents || [] }) }, fetchSkills: async () => { const skills = await fetch(`${API}/skills`).then(r => r.json()) set({ skills: skills || [] }) }, connectRoom: (roomId) => { const { ws } = get() // 已有连接且状态正常则跳过 if (ws[roomId] && ws[roomId].readyState <= 1) return const socket = new WebSocket(`ws://${location.host}/ws/${roomId}`) // 断线自动重连 socket.onclose = () => { set(s => { const newWs = { ...s.ws } delete newWs[roomId] return { ws: newWs } }) // 3 秒后重连 setTimeout(() => get().connectRoom(roomId), 3000) } // 心跳保活:每 30 秒发一次 ping const heartbeat = setInterval(() => { if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: 'ping' })) } }, 30000) socket.addEventListener('close', () => clearInterval(heartbeat)) socket.onmessage = (e) => { const ev: WsEvent = JSON.parse(e.data) if (ev.type === 'agent_message') { set(s => { const msgs = [...(s.messages[roomId] || [])] // tool_use 消息:同一个 agent 只保留一条,持续更新状态 if (ev.role === 'tool_use') { let idx = -1 for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].role === 'tool_use' && msgs[i].agent === ev.agent) { idx = i; break } } if (idx !== -1) { msgs[idx] = { ...msgs[idx], content: ev.content || msgs[idx].content, streaming: ev.streaming } } else if (ev.content) { msgs.push({ id: Date.now().toString(), agent: ev.agent, role: 'tool_use', content: ev.content, streaming: ev.streaming }) } // 同步更新 todoItem 的 action 描述,同时将 assigned 转为 working const actionText = ev.streaming ? (ev.content === 'thinking' ? '正在整理结果...' : '正在搜索...') : undefined const todos = (s.todoItems[roomId] || []).map(t => t.agent === ev.agent && (t.status === 'working' || t.status === 'assigned') ? { ...t, status: 'working' as const, action: actionText || t.action } : t ) return { messages: { ...s.messages, [roomId]: msgs }, todoItems: { ...s.todoItems, [roomId]: todos }, } } // 普通消息:streaming 合并逻辑 let idx = -1 for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].streaming && msgs[i].agent === ev.agent) { idx = i; break } } if (idx !== -1) { if (ev.action === 'replace') { // action=replace: 用新内容替换(文件驱动模式,文档已保存到 workspace) msgs[idx] = { ...msgs[idx], content: ev.content || '', streaming: false } } else { msgs[idx] = { ...msgs[idx], content: msgs[idx].content + (ev.content || ''), streaming: ev.streaming } } } else if (ev.action === 'replace') { // 找最后一条同 agent 消息替换,找不到则新增 let found = false for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].agent === ev.agent && msgs[i].role === ev.role) { msgs[i] = { ...msgs[i], content: ev.content || '', streaming: false } found = true break } } if (!found && ev.content) { msgs.push({ id: Date.now().toString(), agent: ev.agent, role: ev.role, content: ev.content, streaming: false }) } } else if (ev.content) { msgs.push({ id: Date.now().toString(), agent: ev.agent, role: ev.role, content: ev.content, streaming: ev.streaming, created_at: new Date().toISOString() }) } return { messages: { ...s.messages, [roomId]: msgs } } }) } else if (ev.type === 'room_status') { set(s => { let todos = [...(s.todoItems[roomId] || [])] if (ev.active_agent && ev.status === 'working') { // 更新活跃 agent 状态为 working,附带 action 描述 todos = todos.map(t => t.agent === ev.active_agent ? { ...t, status: 'working' as const, action: ev.action || t.action } : t ) } // 记录工作开始时间(thinking/working 时设置,pending 时清除) const workStartedAt = { ...s.workStartedAt } if (ev.status === 'pending') { delete workStartedAt[roomId] } else if (!workStartedAt[roomId]) { workStartedAt[roomId] = Date.now() } // pending 时:清理所有残留 streaming 消息,只在所有任务都 done 后清空 todos if (ev.status === 'pending') { const cleanMsgs = (s.messages[roomId] || []).map(m => m.streaming ? { ...m, streaming: false } : m ) if (todos.length > 0) { const allDone = todos.every(t => t.status === 'done') if (allDone) todos = [] } return { rooms: s.rooms.map(r => r.id === roomId ? { ...r, status: ev.status, activeAgent: ev.active_agent, action: ev.action } : r), messages: { ...s.messages, [roomId]: cleanMsgs }, todoItems: { ...s.todoItems, [roomId]: todos }, workingFiles: { ...s.workingFiles, [roomId]: [] }, workStartedAt, } } return { rooms: s.rooms.map(r => r.id === roomId ? { ...r, status: ev.status, activeAgent: ev.active_agent, action: ev.action } : r), todoItems: { ...s.todoItems, [roomId]: todos }, workStartedAt, } }) } else if (ev.type === 'tasks_update') { set(s => ({ tasks: { ...s.tasks, [roomId]: ev.content } })) } else if (ev.type === 'workspace_file') { set(s => { const files = [...(s.workspace[roomId] || [])] if (!files.includes(ev.filename)) { files.push(ev.filename) } return { workspace: { ...s.workspace, [roomId]: files } } }) } else if (ev.type === 'task_assign') { set(s => { const msgs = [...(s.messages[roomId] || [])] msgs.push({ id: Date.now().toString(), agent: ev.from, role: 'task_assign' as const, content: ev.task, title: ev.to, }) // 去重:同一 agent 只保留最新任务,清除已完成的 const existing = (s.todoItems[roomId] || []).filter(t => t.status !== 'done' && t.agent !== ev.to) existing.push({ id: `${Date.now()}-${ev.to}`, agent: ev.to, task: ev.task, status: 'assigned', }) return { messages: { ...s.messages, [roomId]: msgs }, todoItems: { ...s.todoItems, [roomId]: existing }, } }) } else if (ev.type === 'task_done') { set(s => { // 清理该 agent 的残留 streaming 消息 const msgs = (s.messages[roomId] || []).map(m => m.agent === ev.agent && m.streaming ? { ...m, streaming: false } : m ) const todos = (s.todoItems[roomId] || []).map(t => t.agent === ev.agent && t.status !== 'done' ? { ...t, status: 'done' as const } : t ) return { messages: { ...s.messages, [roomId]: msgs }, todoItems: { ...s.todoItems, [roomId]: todos }, } }) } else if (ev.type === 'mode_change') { set(s => ({ rooms: s.rooms.map(r => r.id === roomId ? { ...r, mode: ev.mode } : r) })) } else if (ev.type === 'schedule_run') { set(s => { const msgs = [...(s.messages[roomId] || [])] msgs.push({ id: Date.now().toString(), agent: ev.agent || 'scheduler', role: 'user' as const, content: `[定时任务] ${ev.content}`, }) return { messages: { ...s.messages, [roomId]: msgs } } }) } else if (ev.type === 'token_usage') { set(s => { const prev = s.tokenUsage[roomId] || { promptTokens: 0, completionTokens: 0, totalTokens: 0 } return { tokenUsage: { ...s.tokenUsage, [roomId]: { promptTokens: prev.promptTokens + ev.prompt_tokens, completionTokens: prev.completionTokens + ev.completion_tokens, totalTokens: prev.totalTokens + ev.total_tokens, } } } }) } else if (ev.type === 'artifact') { // 添加 artifact 消息卡片到聊天 set(s => { const msgs = [...(s.messages[roomId] || [])] msgs.push({ id: Date.now().toString(), agent: ev.agent, role: 'artifact' as const, content: ev.title || ev.filename, filename: ev.filename, title: ev.title, }) // 同时更新 workspace 文件列表 const files = [...(s.workspace[roomId] || [])] if (!files.includes(ev.filename)) { files.push(ev.filename) } return { messages: { ...s.messages, [roomId]: msgs }, workspace: { ...s.workspace, [roomId]: files }, } }) } else if (ev.type === 'file_working') { set(s => { const files = [...(s.workingFiles[roomId] || [])] // 避免重复 if (!files.some(f => f.agent === ev.agent && f.filename === ev.filename)) { files.push({ agent: ev.agent, filename: ev.filename, title: ev.title }) } return { workingFiles: { ...s.workingFiles, [roomId]: files } } }) } else if (ev.type === 'file_done') { set(s => { const files = (s.workingFiles[roomId] || []).filter( f => !(f.agent === ev.agent && f.filename === ev.filename) ) return { workingFiles: { ...s.workingFiles, [roomId]: files } } }) } else if (ev.type === 'file_read') { set(s => { const roomReaders = { ...(s.fileReaders[roomId] || {}) } const readers = [...(roomReaders[ev.filename] || [])] if (!readers.includes(ev.agent)) { readers.push(ev.agent) } roomReaders[ev.filename] = readers return { fileReaders: { ...s.fileReaders, [roomId]: roomReaders } } }) } } set(s => ({ ws: { ...s.ws, [roomId]: socket } })) }, sendMessage: (roomId, content) => { const { ws, user } = get() const userName = user?.name || '用户' const userMsg = { id: Date.now().toString(), agent: userName, role: 'user' as const, content, created_at: new Date().toISOString() } set(s => ({ messages: { ...s.messages, [roomId]: [...(s.messages[roomId] || []), userMsg] } })) ws[roomId]?.send(JSON.stringify({ type: 'user_message', content, user_name: userName })) }, markFileRead: (roomId: string, filename: string) => { const userName = get().user?.name || '用户' set(s => { const roomReaders = { ...(s.fileReaders[roomId] || {}) } const readers = [...(roomReaders[filename] || [])] if (!readers.includes(userName)) { readers.push(userName) } roomReaders[filename] = readers return { fileReaders: { ...s.fileReaders, [roomId]: roomReaders } } }) }, setRoomTeam: async (roomId, team) => { const res = await fetch(`${API}/rooms/${roomId}/team`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ team }) }) if (res.ok) { const data = await res.json() set(s => ({ rooms: s.rooms.map(r => r.id === roomId ? { ...r, team, master: data.master, members: data.members } : r) })) } }, } })