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

681 lines
23 KiB
TypeScript

import { create } from 'zustand';
import {
createImageWorkspaceOperationId,
createImageWorkspaceProject,
deleteImageWorkspaceProject,
fetchImageWorkspace,
fetchImageWorkspaceProject,
ImageWorkspaceApiError,
openImageWorkspaceEvents,
renameImageWorkspaceProject,
submitImageWorkspaceCommand,
} from '@/lib/image-workspace';
import { setDesktopBackgroundLease } from '@/lib/host-api';
import { useAuthStore } from '@/stores/auth';
import {
IMAGE_WORKSPACE_UNAVAILABLE_CODE,
type DesignCommandInput,
type DesignCompilationIssue,
type DesignDeleteWorkspaceResult,
type DesignFieldChange,
type DesignGenerationTask,
type DesignUserFieldOperation,
type DesignWorkspace,
type DesignWorkspaceBootstrap,
type DesignWorkspaceEvent,
type DesignWorkspaceSummary,
} from '../../shared/image-workspace';
export type ImageWorkspaceLoadStatus =
| 'idle'
| 'loading'
| 'ready'
| 'unavailable'
| 'error'
| 'auth-required';
export type ImageWorkspaceEventState = 'idle' | 'connecting' | 'connected' | 'degraded';
export type PendingDesignOperation = {
id: string;
label: string;
command: DesignCommandInput;
status: 'submitting' | 'unknown';
error: string | null;
clearDraftPaths: string[];
clearChatDraft: boolean;
};
type ExecuteCommandOptions = {
label: string;
clearDraftPaths?: string[];
clearChatDraft?: boolean;
};
type ImageWorkspaceState = {
status: ImageWorkspaceLoadStatus;
bootstrap: DesignWorkspaceBootstrap | null;
activeWorkspaceId: string | null;
workspace: DesignWorkspace | null;
deletingWorkspaceId: string | null;
eventState: ImageWorkspaceEventState;
lastEventId: string | null;
pendingOperations: Record<string, PendingDesignOperation>;
fieldDrafts: Record<string, unknown>;
chatDraft: string;
assistantStreams: Record<string, string>;
quoteBlockers: DesignCompilationIssue[];
error: string | null;
load: () => Promise<DesignWorkspaceBootstrap | null>;
createProject: (title: string) => Promise<DesignWorkspace>;
renameProject: (workspaceId: string, title: string) => Promise<DesignWorkspace>;
deleteProject: (workspaceId: string) => Promise<DesignDeleteWorkspaceResult>;
selectProject: (workspaceId: string) => Promise<void>;
refreshWorkspace: () => Promise<DesignWorkspace | null>;
connectEvents: () => void;
disconnectEvents: () => void;
setFieldDraft: (path: string, value: unknown) => void;
discardFieldDraft: (path: string) => void;
setChatDraft: (value: string) => void;
applyFieldOperations: (
operations: DesignUserFieldOperation[],
clearDraftPaths?: string[],
) => Promise<DesignWorkspace>;
sendChat: () => Promise<DesignWorkspace>;
resolveDecisionPrompt: (
promptId: string,
action: 'select' | 'reject',
optionId?: string,
) => Promise<DesignWorkspace>;
requestQuote: () => Promise<DesignWorkspace>;
confirmGeneration: (quoteId: string) => Promise<DesignWorkspace>;
retryOperation: (clientOperationId: string) => Promise<DesignWorkspace>;
reset: () => void;
};
let inFlightLoad: Promise<DesignWorkspaceBootstrap | null> | null = null;
let activeEventSource: EventSource | null = null;
let activeEventWorkspaceId: string | null = null;
let selectionGeneration = 0;
function updateBackgroundLease(tasks: DesignGenerationTask[]): void {
const active = tasks.some((task) => task.status === 'queued' || task.status === 'running');
try {
void Promise.resolve(setDesktopBackgroundLease({
id: 'design-generation',
kind: 'design-generation',
active,
})).catch(() => undefined);
} catch {
// Renderer-only previews may not expose lifecycle IPC.
}
}
function closeEventSource(): void {
if (activeEventSource) {
activeEventSource.onopen = null;
activeEventSource.onerror = null;
activeEventSource.close();
}
activeEventSource = null;
activeEventWorkspaceId = null;
}
function messageForError(error: unknown): string {
if (error instanceof Error && error.message.trim()) return error.message;
return 'AI 设计暂时无法同步,请稍后重试';
}
function isAuthError(error: unknown): boolean {
return error instanceof ImageWorkspaceApiError && (error.status === 401 || error.status === 403);
}
function isUnavailableError(error: unknown): boolean {
return error instanceof ImageWorkspaceApiError
&& (error.status === 501 || error.code === IMAGE_WORKSPACE_UNAVAILABLE_CODE);
}
function isDirectionConflict(error: unknown): boolean {
return error instanceof ImageWorkspaceApiError
&& (error.status === 409
|| error.code === 'DESIGN_DIRECTION_REVISION_CONFLICT'
|| error.code === 'DIRECTION_REVISION_CONFLICT');
}
function parseWorkspaceEvent(event: Event): DesignWorkspaceEvent | null {
const data = (event as MessageEvent<unknown>).data;
if (typeof data !== 'string') return null;
try {
const parsed = JSON.parse(data) as Partial<DesignWorkspaceEvent>;
if (typeof parsed.id !== 'string' || typeof parsed.type !== 'string') return null;
if (![
'design.session.snapshot',
'design.assistant.delta',
'design.direction.updated',
'design.quote.blocked',
'design.workspace.updated',
].includes(parsed.type)) return null;
return parsed as DesignWorkspaceEvent;
} catch {
return null;
}
}
function mergeTask(
tasks: DesignGenerationTask[],
incoming: DesignGenerationTask,
): DesignGenerationTask[] {
const existing = tasks.find((task) => task.taskId === incoming.taskId);
if (existing && existing.taskRevision > incoming.taskRevision) return tasks;
return [incoming, ...tasks.filter((task) => task.taskId !== incoming.taskId)]
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt));
}
function mergeSummary(
summaries: DesignWorkspaceSummary[],
summary: DesignWorkspaceSummary,
): DesignWorkspaceSummary[] {
return [summary, ...summaries.filter((item) => item.workspaceId !== summary.workspaceId)]
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
}
function withWorkspace(
state: ImageWorkspaceState,
workspace: DesignWorkspace,
): Partial<ImageWorkspaceState> {
updateBackgroundLease(workspace.tasks);
return {
status: 'ready',
activeWorkspaceId: workspace.workspace.workspaceId,
workspace,
bootstrap: state.bootstrap
? {
...state.bootstrap,
workspaces: mergeSummary(state.bootstrap.workspaces, workspace.workspace),
}
: state.bootstrap,
error: null,
};
}
function applyEventToWorkspace(
workspace: DesignWorkspace,
event: DesignWorkspaceEvent,
): DesignWorkspace {
if (event.type === 'design.session.snapshot' || event.type === 'design.direction.updated') {
return {
...workspace,
workspace: {
...workspace.workspace,
directionRevision: event.form.directionRevision,
specificationRevision: event.form.specificationRevision,
workspaceViewRevision: event.form.workspaceViewRevision,
},
form: event.form,
};
}
if (event.type === 'design.quote.blocked') {
return {
...workspace,
workspace: {
...workspace.workspace,
directionRevision: event.form.directionRevision,
specificationRevision: event.form.specificationRevision,
workspaceViewRevision: event.form.workspaceViewRevision,
},
form: event.form,
};
}
if (event.type === 'design.workspace.updated' && event.changedTask) {
return {
...workspace,
workspace: {
...workspace.workspace,
workspaceViewRevision: Math.max(
workspace.workspace.workspaceViewRevision,
event.workspaceViewRevision,
),
},
tasks: mergeTask(workspace.tasks, event.changedTask),
};
}
return workspace;
}
export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) => {
const refreshWorkspaceById = async (workspaceId: string): Promise<DesignWorkspace> => {
const generation = ++selectionGeneration;
const workspace = await fetchImageWorkspaceProject(workspaceId);
if (generation !== selectionGeneration || get().activeWorkspaceId !== workspaceId) {
return workspace;
}
set((state) => withWorkspace(state, workspace));
return workspace;
};
const executeCommand = async (
command: DesignCommandInput,
options: ExecuteCommandOptions,
): Promise<DesignWorkspace> => {
const operation: PendingDesignOperation = {
id: command.clientOperationId,
label: options.label,
command,
status: 'submitting',
error: null,
clearDraftPaths: options.clearDraftPaths ?? [],
clearChatDraft: options.clearChatDraft ?? false,
};
set((state) => ({
pendingOperations: { ...state.pendingOperations, [operation.id]: operation },
error: null,
quoteBlockers: command.kind === 'request_quote' ? [] : state.quoteBlockers,
}));
try {
const result = await submitImageWorkspaceCommand(command);
set((state) => {
const pendingOperations = { ...state.pendingOperations };
delete pendingOperations[operation.id];
const fieldDrafts = { ...state.fieldDrafts };
for (const path of operation.clearDraftPaths) delete fieldDrafts[path];
const assistantStreams = { ...state.assistantStreams };
delete assistantStreams[operation.id];
return {
...withWorkspace(state, result.workspace),
pendingOperations,
fieldDrafts,
chatDraft: operation.clearChatDraft ? '' : state.chatDraft,
assistantStreams,
};
});
return result.workspace;
} catch (error) {
const message = messageForError(error);
if (isDirectionConflict(error)) {
set((state) => {
const pendingOperations = { ...state.pendingOperations };
delete pendingOperations[operation.id];
return { pendingOperations, error: '设计已在其他位置更新,已刷新最新内容,请再次提交' };
});
await get().refreshWorkspace().catch(() => null);
} else if (isAuthError(error)) {
set((state) => {
const pendingOperations = { ...state.pendingOperations };
delete pendingOperations[operation.id];
return { pendingOperations, status: 'auth-required', error: message };
});
} else {
set((state) => ({
pendingOperations: {
...state.pendingOperations,
[operation.id]: { ...operation, status: 'unknown', error: message },
},
error: '操作结果尚未确认,可使用同一操作标识安全重试',
}));
}
throw error;
}
};
return {
status: 'idle',
bootstrap: null,
activeWorkspaceId: null,
workspace: null,
deletingWorkspaceId: null,
eventState: 'idle',
lastEventId: null,
pendingOperations: {},
fieldDrafts: {},
chatDraft: '',
assistantStreams: {},
quoteBlockers: [],
error: null,
load: async () => {
if (inFlightLoad) return inFlightLoad;
inFlightLoad = (async () => {
if (!useAuthStore.getState().isAuthenticated()) {
set({ status: 'auth-required', error: null });
return null;
}
set({ status: 'loading', error: null });
try {
const bootstrap = await fetchImageWorkspace();
const currentId = get().activeWorkspaceId;
const selectedId = currentId && bootstrap.workspaces.some(
(item) => item.workspaceId === currentId,
)
? currentId
: bootstrap.workspaces[0]?.workspaceId ?? null;
set({
status: 'ready',
bootstrap,
activeWorkspaceId: selectedId,
workspace: selectedId === currentId ? get().workspace : null,
error: null,
});
if (selectedId) {
await refreshWorkspaceById(selectedId);
get().connectEvents();
} else {
closeEventSource();
}
return bootstrap;
} catch (error) {
set({
status: isAuthError(error)
? 'auth-required'
: isUnavailableError(error)
? 'unavailable'
: 'error',
error: messageForError(error),
});
return null;
} finally {
inFlightLoad = null;
}
})();
return inFlightLoad;
},
createProject: async (title) => {
const workspace = await createImageWorkspaceProject(title);
selectionGeneration += 1;
closeEventSource();
set((state) => ({
...withWorkspace(state, workspace),
fieldDrafts: {},
chatDraft: '',
quoteBlockers: [],
lastEventId: null,
}));
get().connectEvents();
return workspace;
},
renameProject: async (workspaceId, title) => {
const workspace = await renameImageWorkspaceProject(workspaceId, title);
set((state) => {
if (state.activeWorkspaceId === workspaceId) {
return withWorkspace(state, workspace);
}
return {
bootstrap: state.bootstrap
? {
...state.bootstrap,
workspaces: mergeSummary(state.bootstrap.workspaces, workspace.workspace),
}
: state.bootstrap,
error: null,
};
});
return workspace;
},
deleteProject: async (workspaceId) => {
set({ deletingWorkspaceId: workspaceId, error: null });
try {
const result = await deleteImageWorkspaceProject(workspaceId);
const remaining = get().bootstrap?.workspaces.filter(
(item) => item.workspaceId !== workspaceId,
) ?? [];
const deletingActive = get().activeWorkspaceId === workspaceId;
if (deletingActive) {
selectionGeneration += 1;
closeEventSource();
}
set((state) => ({
bootstrap: state.bootstrap ? { ...state.bootstrap, workspaces: remaining } : null,
activeWorkspaceId: deletingActive ? remaining[0]?.workspaceId ?? null : state.activeWorkspaceId,
workspace: deletingActive ? null : state.workspace,
deletingWorkspaceId: null,
fieldDrafts: deletingActive ? {} : state.fieldDrafts,
chatDraft: deletingActive ? '' : state.chatDraft,
error: null,
}));
if (deletingActive && remaining[0]) {
await refreshWorkspaceById(remaining[0].workspaceId);
get().connectEvents();
}
return result;
} catch (error) {
set({ deletingWorkspaceId: null, error: messageForError(error) });
throw error;
}
},
selectProject: async (workspaceId) => {
if (workspaceId === get().activeWorkspaceId && get().workspace) return;
selectionGeneration += 1;
closeEventSource();
set({
activeWorkspaceId: workspaceId,
workspace: null,
eventState: 'connecting',
lastEventId: null,
fieldDrafts: {},
chatDraft: '',
quoteBlockers: [],
error: null,
});
try {
await refreshWorkspaceById(workspaceId);
get().connectEvents();
} catch (error) {
set({ error: messageForError(error), eventState: 'degraded' });
}
},
refreshWorkspace: async () => {
const workspaceId = get().activeWorkspaceId;
if (!workspaceId) return null;
return refreshWorkspaceById(workspaceId);
},
connectEvents: () => {
const workspace = get().workspace;
if (!workspace) return;
const workspaceId = workspace.workspace.workspaceId;
if (activeEventSource && activeEventWorkspaceId === workspaceId) return;
closeEventSource();
activeEventWorkspaceId = workspaceId;
set({ eventState: 'connecting' });
void openImageWorkspaceEvents(
workspaceId,
workspace.workspace.sessionId,
get().lastEventId ?? undefined,
).then((source) => {
if (activeEventWorkspaceId !== workspaceId || get().activeWorkspaceId !== workspaceId) {
source.close();
return;
}
activeEventSource = source;
source.onopen = () => set({ eventState: 'connected' });
source.onerror = () => {
set({ eventState: 'degraded' });
void get().refreshWorkspace().catch(() => null);
};
const receive = (raw: Event) => {
const event = parseWorkspaceEvent(raw);
if (!event || get().activeWorkspaceId !== workspaceId) return;
if (event.type === 'design.assistant.delta') {
set((state) => ({
lastEventId: event.id,
assistantStreams: {
...state.assistantStreams,
[event.clientOperationId]: `${state.assistantStreams[event.clientOperationId] ?? ''}${event.delta}`,
},
}));
return;
}
set((state) => {
if (!state.workspace) return { lastEventId: event.id };
const nextWorkspace = applyEventToWorkspace(state.workspace, event);
updateBackgroundLease(nextWorkspace.tasks);
return {
workspace: nextWorkspace,
bootstrap: state.bootstrap
? {
...state.bootstrap,
workspaces: mergeSummary(state.bootstrap.workspaces, nextWorkspace.workspace),
}
: null,
lastEventId: event.id,
quoteBlockers: event.type === 'design.quote.blocked'
? event.blockers
: state.quoteBlockers,
};
});
if (event.type === 'design.workspace.updated' && event.changedDirection) {
void get().refreshWorkspace().catch(() => null);
}
};
for (const eventType of [
'design.session.snapshot',
'design.assistant.delta',
'design.direction.updated',
'design.quote.blocked',
'design.workspace.updated',
]) source.addEventListener(eventType, receive);
}).catch((error) => {
if (activeEventWorkspaceId === workspaceId) {
set({ eventState: 'degraded', error: messageForError(error) });
}
});
},
disconnectEvents: () => {
closeEventSource();
set({ eventState: 'idle' });
},
setFieldDraft: (path, value) => set((state) => ({
fieldDrafts: { ...state.fieldDrafts, [path]: value },
})),
discardFieldDraft: (path) => set((state) => {
const fieldDrafts = { ...state.fieldDrafts };
delete fieldDrafts[path];
return { fieldDrafts };
}),
setChatDraft: (value) => set({ chatDraft: value }),
applyFieldOperations: async (operations, clearDraftPaths = []) => {
const workspace = get().workspace;
if (!workspace) throw new Error('请先选择一个设计项目');
const command: DesignCommandInput = {
kind: 'apply_input',
workspaceId: workspace.workspace.workspaceId,
sessionId: workspace.workspace.sessionId,
expectedDirectionRevision: workspace.form.directionRevision,
clientOperationId: createImageWorkspaceOperationId(),
input: { kind: 'direct_edit', operations },
};
return executeCommand(command, {
label: '保存设计字段',
clearDraftPaths,
});
},
sendChat: async () => {
const workspace = get().workspace;
const message = get().chatDraft.trim();
if (!workspace) throw new Error('请先选择一个设计项目');
if (!message) throw new Error('请输入想法或修改要求');
const command: DesignCommandInput = {
kind: 'apply_input',
workspaceId: workspace.workspace.workspaceId,
sessionId: workspace.workspace.sessionId,
expectedDirectionRevision: workspace.form.directionRevision,
clientOperationId: createImageWorkspaceOperationId(),
input: { kind: 'chat', message },
};
return executeCommand(command, { label: '发送设计对话', clearChatDraft: true });
},
resolveDecisionPrompt: async (promptId, action, optionId) => {
const workspace = get().workspace;
if (!workspace) throw new Error('请先选择一个设计项目');
const command: DesignCommandInput = {
kind: 'apply_input',
workspaceId: workspace.workspace.workspaceId,
sessionId: workspace.workspace.sessionId,
expectedDirectionRevision: workspace.form.directionRevision,
clientOperationId: createImageWorkspaceOperationId(),
input: {
kind: 'prompt_resolution',
promptId,
action,
...(optionId ? { optionId } : {}),
},
};
return executeCommand(command, { label: '确认设计决策' });
},
requestQuote: async () => {
const workspace = get().workspace;
if (!workspace) throw new Error('请先选择一个设计项目');
const command: DesignCommandInput = {
kind: 'request_quote',
workspaceId: workspace.workspace.workspaceId,
sessionId: workspace.workspace.sessionId,
expectedDirectionRevision: workspace.form.directionRevision,
specificationRevision: workspace.form.specificationRevision,
clientOperationId: createImageWorkspaceOperationId(),
};
return executeCommand(command, { label: '编译生成方案' });
},
confirmGeneration: async (quoteId) => {
const workspace = get().workspace;
if (!workspace) throw new Error('请先选择一个设计项目');
const command: DesignCommandInput = {
kind: 'confirm_generation',
workspaceId: workspace.workspace.workspaceId,
sessionId: workspace.workspace.sessionId,
quoteId,
expectedDirectionRevision: workspace.form.directionRevision,
clientOperationId: createImageWorkspaceOperationId(),
};
return executeCommand(command, { label: '确认生成' });
},
retryOperation: async (clientOperationId) => {
const pending = get().pendingOperations[clientOperationId];
if (!pending) throw new Error('待重试操作不存在');
return executeCommand(pending.command, {
label: pending.label,
clearDraftPaths: pending.clearDraftPaths,
clearChatDraft: pending.clearChatDraft,
});
},
reset: () => {
selectionGeneration += 1;
closeEventSource();
updateBackgroundLease([]);
set({
status: 'idle',
bootstrap: null,
activeWorkspaceId: null,
workspace: null,
deletingWorkspaceId: null,
eventState: 'idle',
lastEventId: null,
pendingOperations: {},
fieldDrafts: {},
chatDraft: '',
assistantStreams: {},
quoteBlockers: [],
error: null,
});
},
};
});
export function recentDesignChanges(workspace: DesignWorkspace | null): DesignFieldChange[] {
return workspace?.form.recentChangeSet.changes ?? [];
}