import { useStore } from 'zustand'; import { createStore, type StoreApi } from 'zustand/vanilla'; import { createCodingProjectConversation, createCodingProject, getCodingProjectConfig, listCodingProjectConversations, listCodingProjects, 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; } 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; refreshConversations(projectId: string): 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; 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.id === config.defaultAgentId && agent.enabled && !agent.archivedAt) ?? 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, }; } export function createCodingWorkspaceStore( dependencies: Partial = {}, ): StoreApi { const deps = { ...defaultDependencies(), ...dependencies }; const conversationFlights = new Map>(); let loadFlight: Promise | null = null; let loadGeneration = 0; let metadataRevision = 0; let metadataTail = Promise.resolve(); let activationIntent = 0; let activationTail: Promise = Promise.resolve(); const refreshFlights = new Map }>(); return createStore((set, get) => ({ projects: [], activeProjectId: null, activeProject: null, config: null, conversations: [], selectedAgentId: null, loadState: 'idle', error: null, conversationErrorsByProjectId: {}, creatingAgentIds: {}, async refreshConversations(projectId) { if (get().activeProjectId !== projectId) return; const existing = refreshFlights.get(projectId); if (existing) { existing.dirty = true; return await existing.promise; } const refresh = { dirty: true, promise: Promise.resolve() }; refresh.promise = (async () => { while (refresh.dirty && get().activeProjectId === projectId) { refresh.dirty = false; await metadataTail; const revision = metadataRevision; const generation = loadGeneration; const conversations = await deps.listConversations(projectId); if (get().activeProjectId !== projectId || generation !== loadGeneration) return; if (revision !== metadataRevision) { refresh.dirty = true; continue; } if (!refresh.dirty) set({ conversations }); } })().finally(() => { refreshFlights.delete(projectId); }); refreshFlights.set(projectId, refresh); return await refresh.promise; }, 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; } if (get().activeProjectId === activeProject.id) await metadataTail; const revision = metadataRevision; 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: revision === metadataRevision || get().activeProjectId !== activeProject.id ? conversations : get().conversations, selectedAgentId: currentAgentId ?? firstEnabledAgent(snapshot.config)?.id ?? null, loadState: 'ready', error: null, }); if (revision !== metadataRevision) void get().refreshConversations(activeProject.id).catch(() => undefined); }) .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 intent = ++activationIntent; ++loadGeneration; loadFlight = null; const activation = activationTail.catch(() => undefined).then(() => deps.setActiveProject(projectId)); activationTail = activation; const project = await activation; if (intent === activationIntent) await get().load(); return project; }, async removeProject(projectId) { await deps.removeProject(projectId); await get().load(); }, 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(); agentId ??= state.config?.defaultAgentId ?? (state.config ? firstEnabledAgent(state.config)?.id : undefined); if (!state.activeProject || !enabledAgent(state.config, agentId ?? null)) { throw new Error('当前项目没有可用的智能体。'); } const resolvedAgentId = agentId!; const flightKey = `${state.activeProject.id}:${resolvedAgentId}`; const existingFlight = conversationFlights.get(flightKey); if (existingFlight) return await existingFlight; set((current) => ({ creatingAgentIds: { ...current.creatingAgentIds, [resolvedAgentId]: true }, error: null, })); let flight: Promise; flight = deps.createConversation({ projectId: state.activeProject.id, agentId, title: '新对话', }).then((conversation) => { metadataRevision += 1; 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[resolvedAgentId]; return { creatingAgentIds }; }); }); conversationFlights.set(flightKey, flight); return await flight; }, async patchConversation(conversationId, patch) { const sourceProjectId = get().activeProjectId; if (!sourceProjectId) throw new Error('当前没有可用的项目。'); metadataRevision += 1; const flight = metadataTail.then(() => deps.patchConversation(conversationId, patch)); metadataTail = flight.then(() => undefined, () => undefined); set((current) => ({ conversationErrorsByProjectId: withConversationError( current.conversationErrorsByProjectId, sourceProjectId, conversationId, null, ), })); try { const conversation = await flight; get().upsertConversation(sourceProjectId, conversation); return conversation; } catch (error) { metadataRevision += 1; const message = error instanceof Error ? error.message : String(error); set((current) => ({ conversationErrorsByProjectId: withConversationError( current.conversationErrorsByProjectId, sourceProjectId, conversationId, message, ), })); throw error; } }, upsertConversation(projectId, conversation) { metadataRevision += 1; 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); }