348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
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<CodingProjectCatalog>;
|
|
getConfig(projectId: string): Promise<{
|
|
project: CodingProjectSummary;
|
|
config: CodingProjectConfig;
|
|
}>;
|
|
listConversations(projectId: string): Promise<CodingConversationMetadata[]>;
|
|
createConversation(input: {
|
|
projectId: string;
|
|
agentId: string;
|
|
title: string;
|
|
}): Promise<CodingConversationMetadata>;
|
|
patchConversation(
|
|
conversationId: string,
|
|
patch: { title?: string; archived?: boolean; unread?: boolean },
|
|
): Promise<CodingConversationMetadata>;
|
|
openProject(projectPath: string): Promise<CodingProjectSummary>;
|
|
createProject(input: {
|
|
projectPath?: string;
|
|
parentPath?: string;
|
|
projectName?: string;
|
|
projectType?: ProjectType;
|
|
identity: ProjectIdentityChoice;
|
|
}): Promise<{ project: CodingProjectSummary; config: CodingProjectConfig; knowledgeFiles: string[] }>;
|
|
setActiveProject(projectId: string): Promise<CodingProjectSummary>;
|
|
removeProject(projectId: string): Promise<void>;
|
|
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<string, Record<string, string>>;
|
|
creatingAgentIds: Record<string, true>;
|
|
load(): Promise<void>;
|
|
openProject(projectPath: string): Promise<CodingProjectSummary>;
|
|
createProject(input: {
|
|
projectPath?: string;
|
|
parentPath?: string;
|
|
projectName?: string;
|
|
projectType?: ProjectType;
|
|
identity: ProjectIdentityChoice;
|
|
}): Promise<CodingProjectSummary>;
|
|
setActiveProject(projectId: string): Promise<CodingProjectSummary>;
|
|
removeProject(projectId: string): Promise<void>;
|
|
acknowledgeLegacyNotice(): Promise<void>;
|
|
selectAgent(agentId: string): void;
|
|
ensureConversation(agentId: string): Promise<CodingConversationMetadata>;
|
|
createConversation(agentId: string): Promise<CodingConversationMetadata>;
|
|
patchConversation(
|
|
conversationId: string,
|
|
patch: { title?: string; archived?: boolean; unread?: boolean },
|
|
): Promise<CodingConversationMetadata>;
|
|
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<string, Record<string, string>>,
|
|
projectId: string,
|
|
conversationId: string,
|
|
message: string | null,
|
|
): Record<string, Record<string, string>> {
|
|
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<CodingWorkspaceDependencies> = {},
|
|
): StoreApi<CodingWorkspaceState> {
|
|
const deps = { ...defaultDependencies(), ...dependencies };
|
|
const conversationFlights = new Map<string, Promise<CodingConversationMetadata>>();
|
|
let loadFlight: Promise<void> | null = null;
|
|
let loadGeneration = 0;
|
|
|
|
return createStore<CodingWorkspaceState>((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<void>;
|
|
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<CodingConversationMetadata>;
|
|
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<T>(
|
|
selector: (state: CodingWorkspaceState) => T,
|
|
): T {
|
|
return useStore(codingWorkspaceStore, selector);
|
|
}
|