Files
makelore/src/stores/coding-workspace.ts

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);
}