agent-team/web/src/store.ts

462 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, Message[]>
tasks: Record<string, string>
workspace: Record<string, string[]>
agents: AgentInfo[]
skills: SkillMeta[]
page: 'chat' | 'agents' | 'skills' | 'market' | 'settings'
ws: Record<string, WebSocket>
tokenUsage: Record<string, TokenUsage>
todoItems: Record<string, TodoItem[]>
fileReaders: Record<string, Record<string, string[]>>
workingFiles: Record<string, { agent: string; filename: string; title?: string }[]>
workStartedAt: Record<string, number> // roomId -> timestamp (ms)
setTheme: (theme: 'light' | 'dark') => void
toggleTheme: () => void
setOnboardingCompleted: (completed: boolean) => void
fetchUser: () => Promise<void>
setPage: (p: AppState['page']) => void
setActiveRoom: (id: string) => void
fetchRooms: () => Promise<void>
fetchAgents: () => Promise<void>
fetchSkills: () => Promise<void>
sendMessage: (roomId: string, content: string) => void
connectRoom: (roomId: string) => void
markFileRead: (roomId: string, filename: string) => void
setRoomTeam: (roomId: string, team: string) => Promise<void>
}
const API = '/api'
export const useStore = create<AppState>((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)
}))
}
},
}
})