import { useStore } from 'zustand'; import { createStore, type StoreApi } from 'zustand/vanilla'; import { createCodingProjectConversation, createCodingProject, getCodingProjectConfig, listCodingProjectConversations, listCodingProjects, acknowledgeLegacyCodingConversationNotice, openCodingProject, patchCodingProjectConversation, removeCodingProject, setActiveCodingProject, type CodingProjectCatalog, } from '@/lib/coding-projects'; import type { CodingConversationMetadata, CodingProjectAgent, CodingProjectConfig, CodingProjectSummary, } from '@/types/coding-project'; import type { ProjectType } from '../../shared/project-config'; import type { ProjectIdentityChoice } from '../../shared/coding-project-contracts'; interface CodingWorkspaceDependencies { listProjects(): Promise; getConfig(projectId: string): Promise<{ project: CodingProjectSummary; config: CodingProjectConfig; }>; listConversations(projectId: string): Promise; createConversation(input: { projectId: string; agentId: string; title: string; }): Promise; patchConversation( conversationId: string, patch: { title?: string; archived?: boolean; unread?: boolean }, ): Promise; openProject(projectPath: string): Promise; createProject(input: { projectPath?: string; parentPath?: string; projectName?: string; projectType?: ProjectType; identity: ProjectIdentityChoice; }): Promise<{ project: CodingProjectSummary; config: CodingProjectConfig; knowledgeFiles: string[] }>; setActiveProject(projectId: string): Promise; removeProject(projectId: string): Promise; acknowledgeLegacyNotice(projectId: string): Promise<{ project: CodingProjectSummary; config: CodingProjectConfig; knowledgeFiles: string[]; }>; } export interface CodingWorkspaceState { projects: CodingProjectSummary[]; activeProjectId: string | null; activeProject: CodingProjectSummary | null; config: CodingProjectConfig | null; conversations: CodingConversationMetadata[]; selectedAgentId: string | null; loadState: 'idle' | 'loading' | 'ready' | 'error'; error: string | null; conversationErrorsByProjectId: Record>; creatingAgentIds: Record; load(): Promise; openProject(projectPath: string): Promise; createProject(input: { projectPath?: string; parentPath?: string; projectName?: string; projectType?: ProjectType; identity: ProjectIdentityChoice; }): Promise; setActiveProject(projectId: string): Promise; removeProject(projectId: string): Promise; acknowledgeLegacyNotice(): Promise; selectAgent(agentId: string): void; ensureConversation(agentId: string): Promise; createConversation(agentId: string): Promise; patchConversation( conversationId: string, patch: { title?: string; archived?: boolean; unread?: boolean }, ): Promise; upsertConversation(projectId: string, conversation: CodingConversationMetadata): void; } function enabledAgent(config: CodingProjectConfig | null, agentId: string | null): CodingProjectAgent | null { if (!config || !agentId) return null; return config.agents.find((agent) => ( agent.id === agentId && agent.enabled && !agent.archivedAt )) ?? null; } function firstEnabledAgent(config: CodingProjectConfig): CodingProjectAgent | null { return config.agents.find((agent) => agent.enabled && !agent.archivedAt && agent.pinned) ?? config.agents.find((agent) => agent.enabled && !agent.archivedAt) ?? null; } function newestConversation( conversations: CodingConversationMetadata[], agentId: string, ): CodingConversationMetadata | null { return conversations .filter((conversation) => conversation.agentId === agentId && !conversation.archivedAt) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null; } function withConversationError( errorsByProjectId: Record>, projectId: string, conversationId: string, message: string | null, ): Record> { const next = { ...errorsByProjectId }; const projectErrors = { ...(next[projectId] ?? {}) }; if (message) projectErrors[conversationId] = message; else delete projectErrors[conversationId]; if (Object.keys(projectErrors).length > 0) next[projectId] = projectErrors; else delete next[projectId]; return next; } function defaultDependencies(): CodingWorkspaceDependencies { return { listProjects: listCodingProjects, getConfig: getCodingProjectConfig, listConversations: listCodingProjectConversations, createConversation: createCodingProjectConversation, patchConversation: patchCodingProjectConversation, openProject: openCodingProject, createProject: createCodingProject, setActiveProject: setActiveCodingProject, removeProject: removeCodingProject, acknowledgeLegacyNotice: acknowledgeLegacyCodingConversationNotice, }; } export function createCodingWorkspaceStore( dependencies: Partial = {}, ): StoreApi { const deps = { ...defaultDependencies(), ...dependencies }; const conversationFlights = new Map>(); let loadFlight: Promise | null = null; let loadGeneration = 0; return createStore((set, get) => ({ projects: [], activeProjectId: null, activeProject: null, config: null, conversations: [], selectedAgentId: null, loadState: 'idle', error: null, conversationErrorsByProjectId: {}, creatingAgentIds: {}, async load() { if (loadFlight) return await loadFlight; const generation = ++loadGeneration; set({ loadState: 'loading', error: null }); let flight: Promise; flight = deps.listProjects() .then(async (catalog) => { if (generation !== loadGeneration) return; const activeProject = catalog.projects.find((project) => ( project.id === catalog.activeProjectId )) ?? null; if (!activeProject) { set({ projects: catalog.projects, activeProjectId: null, activeProject: null, config: null, conversations: [], selectedAgentId: null, loadState: 'ready', }); return; } const [snapshot, conversations] = await Promise.all([ deps.getConfig(activeProject.id), deps.listConversations(activeProject.id), ]); if (generation !== loadGeneration) return; const currentAgentId = enabledAgent(snapshot.config, get().selectedAgentId)?.id ?? null; set({ projects: catalog.projects, activeProjectId: activeProject.id, activeProject: snapshot.project, config: snapshot.config, conversations, selectedAgentId: currentAgentId ?? firstEnabledAgent(snapshot.config)?.id ?? null, loadState: 'ready', error: null, }); }) .catch((error) => { if (generation !== loadGeneration) return; set({ loadState: 'error', error: error instanceof Error ? error.message : String(error), }); throw error; }) .finally(() => { if (loadFlight === flight) loadFlight = null; }); loadFlight = flight; await flight; }, async openProject(projectPath) { const project = await deps.openProject(projectPath); await get().load(); return project; }, async createProject(input) { const snapshot = await deps.createProject(input); await get().load(); return snapshot.project; }, async setActiveProject(projectId) { const project = await deps.setActiveProject(projectId); await get().load(); return project; }, async removeProject(projectId) { await deps.removeProject(projectId); await get().load(); }, async acknowledgeLegacyNotice() { const projectId = get().activeProjectId; if (!projectId) return; const snapshot = await deps.acknowledgeLegacyNotice(projectId); if (get().activeProjectId === projectId) set({ config: snapshot.config }); }, selectAgent(agentId) { if (!enabledAgent(get().config, agentId)) return; set({ selectedAgentId: agentId }); }, async ensureConversation(agentId) { const existing = newestConversation(get().conversations, agentId); if (existing) return existing; return await get().createConversation(agentId); }, async createConversation(agentId) { const state = get(); if (!state.activeProject || !enabledAgent(state.config, agentId)) { throw new Error('当前项目没有可用的伙伴。'); } const flightKey = `${state.activeProject.id}:${agentId}`; const existingFlight = conversationFlights.get(flightKey); if (existingFlight) return await existingFlight; set((current) => ({ creatingAgentIds: { ...current.creatingAgentIds, [agentId]: true }, error: null, })); let flight: Promise; flight = deps.createConversation({ projectId: state.activeProject.id, agentId, title: '新对话', }).then((conversation) => { if (get().activeProjectId !== state.activeProject?.id) return conversation; set((current) => ({ conversations: [ conversation, ...current.conversations.filter((item) => item.id !== conversation.id), ], })); return conversation; }).catch((error) => { set({ error: error instanceof Error ? error.message : String(error) }); throw error; }).finally(() => { conversationFlights.delete(flightKey); set((current) => { const creatingAgentIds = { ...current.creatingAgentIds }; delete creatingAgentIds[agentId]; return { creatingAgentIds }; }); }); conversationFlights.set(flightKey, flight); return await flight; }, async patchConversation(conversationId, patch) { const sourceProjectId = get().activeProjectId; if (!sourceProjectId) throw new Error('当前没有可用的项目。'); set((current) => ({ conversationErrorsByProjectId: withConversationError( current.conversationErrorsByProjectId, sourceProjectId, conversationId, null, ), })); try { const conversation = await deps.patchConversation(conversationId, patch); get().upsertConversation(sourceProjectId, conversation); return conversation; } catch (error) { const message = error instanceof Error ? error.message : String(error); set((current) => ({ conversationErrorsByProjectId: withConversationError( current.conversationErrorsByProjectId, sourceProjectId, conversationId, message, ), })); throw error; } }, upsertConversation(projectId, conversation) { if (get().activeProjectId !== projectId) return; set((current) => ({ conversations: [ conversation, ...current.conversations.filter((item) => item.id !== conversation.id), ], })); }, })); } export const codingWorkspaceStore = createCodingWorkspaceStore(); export function useCodingWorkspaceStore( selector: (state: CodingWorkspaceState) => T, ): T { return useStore(codingWorkspaceStore, selector); }