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

1011 lines
36 KiB
TypeScript

import { create } from 'zustand';
import {
confirmImageWorkspaceGeneration,
createImageWorkspaceConversation,
createImageWorkspaceTurnId,
createImageWorkspaceProject,
fetchImageWorkspace,
fetchImageWorkspaceConversation,
fetchImageWorkspaceProject,
fetchImageWorkspaceTasks,
ImageWorkspaceApiError,
openImageWorkspaceTaskEvents,
renameImageWorkspaceProject,
sendImageWorkspaceMessage,
} from '@/lib/image-workspace';
import { useAuthStore } from '@/stores/auth';
import {
IMAGE_WORKSPACE_UNAVAILABLE_CODE,
type DesignAssistantDeltaEvent,
type DesignConversation,
type DesignConversationSnapshotEvent,
type DesignGenerationTask,
type DesignGenerationTaskUpdatedEvent,
type DesignWorkspace,
type DesignWorkspaceBootstrap,
type DesignWorkspaceSummary,
} from '../../shared/image-workspace';
export type ImageWorkspaceLoadStatus =
| 'idle'
| 'loading'
| 'ready'
| 'unavailable'
| 'error'
| 'auth-required';
export type ImageWorkspaceTaskStreamState = 'idle' | 'connecting' | 'connected' | 'degraded';
export type PendingDesignTurn = {
workspaceId: string;
conversationId: string;
clientTurnId: string;
turnRevision: number;
userText: string | null;
assistantText: string;
lastChunkIndex: number;
createdAt: string;
};
type ImageWorkspaceState = {
status: ImageWorkspaceLoadStatus;
bootstrap: DesignWorkspaceBootstrap | null;
activeWorkspaceId: string | null;
activeConversationId: string | null;
workspace: DesignWorkspace | null;
conversation: DesignConversation | null;
creatingConversation: boolean;
tasks: DesignGenerationTask[];
pendingTurn: PendingDesignTurn | null;
taskStreamState: ImageWorkspaceTaskStreamState;
error: string | null;
load: () => Promise<DesignWorkspaceBootstrap | null>;
createProject: (title: string) => Promise<DesignWorkspace>;
createConversation: (title?: string) => Promise<DesignConversation>;
renameProject: (workspaceId: string, title: string) => Promise<DesignWorkspace>;
selectProject: (workspaceId: string) => Promise<void>;
selectConversation: (conversationId: string) => Promise<void>;
refreshWorkspace: () => Promise<DesignWorkspace | null>;
refreshConversation: () => Promise<DesignConversation | null>;
refreshTasks: () => Promise<DesignGenerationTask[]>;
connectTaskStream: () => void;
disconnectTaskStream: () => void;
sendMessage: (message: string, attachmentAssetIds?: string[]) => Promise<DesignConversation>;
confirmGeneration: (quoteId: string) => Promise<DesignConversation>;
reset: () => void;
};
let inFlightLoad: Promise<DesignWorkspaceBootstrap | null> | null = null;
const TASK_FALLBACK_POLL_INTERVAL_MS = 15_000;
const CONFIRMED_TASK_RECONCILE_ATTEMPTS = 5;
const CONFIRMED_TASK_RECONCILE_INTERVAL_MS = 250;
let activeTaskEventSource: EventSource | null = null;
let activeTaskEventWorkspaceId: string | null = null;
let activeTaskEventConversationId: string | null = null;
let taskStreamGeneration = 0;
let taskFallbackTimer: ReturnType<typeof setInterval> | null = null;
const taskEventRevisions = new Map<string, number>();
let workspaceLoadGeneration = 0;
let conversationSelectionGeneration = 0;
function taskRevisionKey(workspaceId: string, taskId: string): string {
return `${workspaceId}:${taskId}`;
}
function stopFallbackPolling(): void {
if (taskFallbackTimer !== null) clearInterval(taskFallbackTimer);
taskFallbackTimer = null;
}
function closeTaskEventSource(): void {
taskStreamGeneration += 1;
stopFallbackPolling();
if (activeTaskEventSource) {
activeTaskEventSource.onopen = null;
activeTaskEventSource.onerror = null;
activeTaskEventSource.close();
}
activeTaskEventSource = null;
activeTaskEventWorkspaceId = null;
activeTaskEventConversationId = null;
taskEventRevisions.clear();
}
function parseTaskUpdatedEvent(event: Event): DesignGenerationTaskUpdatedEvent | null {
const data = (event as MessageEvent<unknown>).data;
if (typeof data !== 'string') return null;
try {
const payload = JSON.parse(data) as Partial<DesignGenerationTaskUpdatedEvent>;
if (payload.type !== 'design.generation_task.updated'
|| typeof payload.id !== 'string'
|| typeof payload.workspaceId !== 'string'
|| !Number.isInteger(payload.workspaceViewRevision)
|| !payload.generationTask
|| typeof payload.generationTask.taskId !== 'string'
|| payload.generationTask.workspaceId !== payload.workspaceId) {
return null;
}
return payload as DesignGenerationTaskUpdatedEvent;
} catch {
return null;
}
}
function parseConversationSnapshotEvent(event: Event): DesignConversationSnapshotEvent | null {
const data = (event as MessageEvent<unknown>).data;
if (typeof data !== 'string') return null;
try {
const payload = JSON.parse(data) as Partial<DesignConversationSnapshotEvent>;
if (payload.type !== 'design.conversation.snapshot'
|| typeof payload.id !== 'string'
|| typeof payload.workspaceId !== 'string'
|| typeof payload.conversationId !== 'string'
|| !Number.isInteger(payload.workspaceViewRevision)
|| !payload.conversation
|| payload.conversation.workspaceId !== payload.workspaceId
|| payload.conversation.conversationId !== payload.conversationId
|| !Array.isArray(payload.conversation.messages)
|| !Array.isArray(payload.generationTasks)
|| !payload.generationTasks.every((task) => (
typeof task?.taskId === 'string' && task.workspaceId === payload.workspaceId
))) {
return null;
}
return payload as DesignConversationSnapshotEvent;
} catch {
return null;
}
}
function parseAssistantDeltaEvent(event: Event): DesignAssistantDeltaEvent | null {
const data = (event as MessageEvent<unknown>).data;
if (typeof data !== 'string') return null;
try {
const payload = JSON.parse(data) as Partial<DesignAssistantDeltaEvent>;
if (payload.type !== 'design.assistant.delta'
|| typeof payload.id !== 'string'
|| typeof payload.workspaceId !== 'string'
|| typeof payload.conversationId !== 'string'
|| typeof payload.clientTurnId !== 'string'
|| !Number.isInteger(payload.turnRevision)
|| Number(payload.turnRevision) < 1
|| !Number.isInteger(payload.chunkIndex)
|| Number(payload.chunkIndex) < 0
|| typeof payload.delta !== 'string'
|| payload.delta.length === 0
|| payload.delta.length > 128) {
return null;
}
return payload as DesignAssistantDeltaEvent;
} catch {
return null;
}
}
function sortTasks(tasks: DesignGenerationTask[]): DesignGenerationTask[] {
return [...tasks].sort((left, right) => (
right.createdAt.localeCompare(left.createdAt) || right.taskId.localeCompare(left.taskId)
));
}
function mergeTasks(
current: DesignGenerationTask[],
incoming: DesignGenerationTask[],
): DesignGenerationTask[] {
const tasksById = new Map(current.map((task) => [task.taskId, task]));
for (const task of incoming) {
const existing = tasksById.get(task.taskId);
if (!existing || existing.updatedAt <= task.updatedAt) tasksById.set(task.taskId, task);
}
return sortTasks([...tasksById.values()]);
}
function unavailable(error: unknown): boolean {
return error instanceof ImageWorkspaceApiError
&& (error.status === 501 || error.code === IMAGE_WORKSPACE_UNAVAILABLE_CODE);
}
function authenticationRequired(error: unknown): boolean {
return error instanceof ImageWorkspaceApiError && error.code === 'AUTH_REQUIRED';
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function toSummary(workspace: DesignWorkspace): DesignWorkspaceSummary {
const { conversations: _conversations, ...summary } = workspace;
return summary;
}
function upsertSummary(
bootstrap: DesignWorkspaceBootstrap | null,
workspace: DesignWorkspace,
): DesignWorkspaceBootstrap | null {
if (!bootstrap) return null;
const summary = toSummary(workspace);
const workspaces = bootstrap.workspaces.filter(
(item) => item.workspaceId !== workspace.workspaceId,
);
return {
...bootstrap,
workspaces: [summary, ...workspaces],
};
}
function upsertConversation(
workspace: DesignWorkspace | null,
conversation: DesignConversation,
): DesignWorkspace | null {
if (!workspace || workspace.workspaceId !== conversation.workspaceId) return workspace;
const conversations = workspace.conversations.filter(
(item) => item.conversationId !== conversation.conversationId,
);
const { messages: _messages, ...summary } = conversation;
return {
...workspace,
conversationCount: Math.max(workspace.conversationCount, conversations.length + 1),
conversations: [summary, ...conversations],
};
}
function createPendingTurn(
conversation: DesignConversation,
clientTurnId: string,
userText: string,
): PendingDesignTurn {
return {
workspaceId: conversation.workspaceId,
conversationId: conversation.conversationId,
clientTurnId,
turnRevision: conversation.turnRevision + 1,
userText,
assistantText: '',
lastChunkIndex: -1,
createdAt: new Date().toISOString(),
};
}
export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) => {
const stopTaskStream = () => {
closeTaskEventSource();
set({ taskStreamState: 'idle' });
};
const startFallbackPolling = () => {
if (taskFallbackTimer !== null) return;
taskFallbackTimer = setInterval(() => {
if (get().taskStreamState !== 'degraded' || !get().activeWorkspaceId) return;
void get().refreshWorkspace()
.catch(() => null)
.then(() => get().refreshConversation().catch(() => null))
.then(() => get().refreshTasks().catch(() => []));
}, TASK_FALLBACK_POLL_INTERVAL_MS);
};
const startTaskStream = (workspaceId: string, conversationId: string) => {
if (activeTaskEventWorkspaceId === workspaceId
&& activeTaskEventConversationId === conversationId
&& get().taskStreamState !== 'idle') return;
closeTaskEventSource();
const generation = taskStreamGeneration;
activeTaskEventWorkspaceId = workspaceId;
activeTaskEventConversationId = conversationId;
set({ taskStreamState: 'connecting' });
void openImageWorkspaceTaskEvents(workspaceId, conversationId).then((source) => {
if (generation !== taskStreamGeneration
|| get().activeWorkspaceId !== workspaceId
|| get().activeConversationId !== conversationId) {
source.close();
return;
}
activeTaskEventSource = source;
source.addEventListener('design.assistant.delta', (event) => {
const delta = parseAssistantDeltaEvent(event);
if (!delta
|| delta.workspaceId !== activeTaskEventWorkspaceId
|| delta.conversationId !== activeTaskEventConversationId) return;
set((state) => {
if (state.activeWorkspaceId !== delta.workspaceId
|| state.activeConversationId !== delta.conversationId
|| (state.conversation?.turnRevision ?? 0) >= delta.turnRevision) {
return state;
}
const pending = state.pendingTurn;
if (!pending) {
if (delta.chunkIndex !== 0) return state;
return {
pendingTurn: {
workspaceId: delta.workspaceId,
conversationId: delta.conversationId,
clientTurnId: delta.clientTurnId,
turnRevision: delta.turnRevision,
userText: null,
assistantText: delta.delta,
lastChunkIndex: delta.chunkIndex,
createdAt: new Date().toISOString(),
},
};
}
if (pending.workspaceId !== delta.workspaceId
|| pending.conversationId !== delta.conversationId
|| pending.clientTurnId !== delta.clientTurnId
|| pending.turnRevision !== delta.turnRevision
|| delta.chunkIndex !== pending.lastChunkIndex + 1) {
return state;
}
return {
pendingTurn: {
...pending,
assistantText: `${pending.assistantText}${delta.delta}`,
lastChunkIndex: delta.chunkIndex,
},
};
});
});
source.addEventListener('design.conversation.snapshot', (event) => {
const snapshot = parseConversationSnapshotEvent(event);
if (!snapshot
|| snapshot.workspaceId !== activeTaskEventWorkspaceId
|| snapshot.conversationId !== activeTaskEventConversationId) return;
set((state) => {
if (state.activeWorkspaceId !== snapshot.workspaceId
|| state.activeConversationId !== snapshot.conversationId) return state;
const tasksById = new Map(state.tasks.map((task) => [task.taskId, task]));
for (const task of snapshot.generationTasks) {
const key = taskRevisionKey(snapshot.workspaceId, task.taskId);
const knownRevision = taskEventRevisions.get(key) ?? 0;
const existing = tasksById.get(task.taskId);
if (snapshot.workspaceViewRevision >= knownRevision
&& (!existing || existing.updatedAt <= task.updatedAt)) {
tasksById.set(task.taskId, task);
}
taskEventRevisions.set(
key,
Math.max(knownRevision, snapshot.workspaceViewRevision),
);
}
const conversation = !state.conversation
|| snapshot.conversation.turnRevision >= state.conversation.turnRevision
? snapshot.conversation
: state.conversation;
const workspace = upsertConversation(
state.workspace
? {
...state.workspace,
viewRevision: Math.max(
state.workspace.viewRevision,
snapshot.workspaceViewRevision,
),
}
: null,
conversation,
);
const bootstrap = workspace?.workspaceId === snapshot.workspaceId
? upsertSummary(state.bootstrap, workspace)
: state.bootstrap;
const pendingTurn = state.pendingTurn?.workspaceId === snapshot.workspaceId
&& state.pendingTurn.conversationId === snapshot.conversationId
&& snapshot.conversation.turnRevision >= state.pendingTurn.turnRevision
? null
: state.pendingTurn;
return {
tasks: sortTasks([...tasksById.values()]),
workspace,
conversation,
bootstrap,
pendingTurn,
};
});
});
source.addEventListener('design.generation_task.updated', (event) => {
const update = parseTaskUpdatedEvent(event);
if (!update || update.workspaceId !== activeTaskEventWorkspaceId) return;
set((state) => {
if (state.activeWorkspaceId !== update.workspaceId) return state;
const key = taskRevisionKey(update.workspaceId, update.generationTask.taskId);
const knownRevision = taskEventRevisions.get(key) ?? 0;
if (update.workspaceViewRevision <= knownRevision) return state;
const existing = state.tasks.find(
(candidate) => candidate.taskId === update.generationTask.taskId,
);
if (existing && existing.updatedAt > update.generationTask.updatedAt) return state;
taskEventRevisions.set(key, update.workspaceViewRevision);
const tasks = sortTasks([
update.generationTask,
...state.tasks.filter((candidate) => candidate.taskId !== update.generationTask.taskId),
]);
const workspace = state.workspace?.workspaceId === update.workspaceId
? {
...state.workspace,
viewRevision: Math.max(
state.workspace.viewRevision,
update.workspaceViewRevision,
),
}
: state.workspace;
const bootstrap = state.bootstrap
? {
...state.bootstrap,
workspaces: state.bootstrap.workspaces.map((candidate) => (
candidate.workspaceId === update.workspaceId
? {
...candidate,
viewRevision: Math.max(
candidate.viewRevision,
update.workspaceViewRevision,
),
}
: candidate
)),
}
: null;
return { tasks, workspace, bootstrap };
});
});
source.onopen = () => {
if (activeTaskEventSource !== source) return;
stopFallbackPolling();
set({ taskStreamState: 'connected' });
};
source.onerror = () => {
if (activeTaskEventSource !== source) return;
set({ taskStreamState: 'degraded' });
startFallbackPolling();
};
}).catch(() => {
if (generation !== taskStreamGeneration
|| get().activeWorkspaceId !== workspaceId
|| get().activeConversationId !== conversationId) return;
set({ taskStreamState: 'degraded' });
startFallbackPolling();
});
};
const handleRequestError = (error: unknown): string => {
const message = messageOf(error);
if (authenticationRequired(error)) {
closeTaskEventSource();
useAuthStore.getState().invalidateSession();
set({
status: 'auth-required',
bootstrap: null,
activeWorkspaceId: null,
activeConversationId: null,
workspace: null,
conversation: null,
creatingConversation: false,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
error: message,
});
}
return message;
};
const refreshWorkspaceTasks = async (
workspaceId: string,
): Promise<DesignGenerationTask[]> => {
const requestedViewRevision = get().workspace?.workspaceId === workspaceId
? get().workspace?.viewRevision ?? 0
: 0;
const tasks = await fetchImageWorkspaceTasks(workspaceId);
const currentWorkspace = get().workspace;
if (get().activeWorkspaceId === workspaceId
&& currentWorkspace?.workspaceId === workspaceId
&& currentWorkspace.viewRevision === requestedViewRevision) {
set((state) => ({ tasks: mergeTasks(state.tasks, tasks) }));
}
return tasks;
};
const applyWorkspace = (workspace: DesignWorkspace): DesignWorkspace => {
set((state) => {
return {
status: 'ready',
bootstrap: upsertSummary(state.bootstrap, workspace),
activeWorkspaceId: workspace.workspaceId,
workspace,
error: null,
};
});
return workspace;
};
const applyConversation = (conversation: DesignConversation): DesignConversation => {
let applied = conversation;
set((state) => {
if (state.conversation?.conversationId === conversation.conversationId
&& (state.conversation.turnRevision > conversation.turnRevision
|| (state.conversation.turnRevision === conversation.turnRevision
&& state.conversation.updatedAt > conversation.updatedAt))) {
applied = state.conversation;
return state;
}
const pendingTurn = state.pendingTurn?.workspaceId === conversation.workspaceId
&& state.pendingTurn.conversationId === conversation.conversationId
&& conversation.turnRevision >= state.pendingTurn.turnRevision
? null
: state.pendingTurn;
return {
workspace: upsertConversation(state.workspace, conversation),
activeConversationId: conversation.conversationId,
conversation,
pendingTurn,
error: null,
};
});
return applied;
};
const loadWorkspace = async (workspaceId: string): Promise<void> => {
const loadGeneration = ++workspaceLoadGeneration;
conversationSelectionGeneration += 1;
const workspace = await fetchImageWorkspaceProject(workspaceId);
if (loadGeneration !== workspaceLoadGeneration
|| get().activeWorkspaceId !== workspaceId) return;
const currentConversationId = get().activeConversationId;
const conversationId = workspace.conversations.some(
(item) => item.conversationId === currentConversationId,
)
? currentConversationId
: workspace.conversations[0]?.conversationId ?? null;
const tasks = await fetchImageWorkspaceTasks(workspaceId);
if (loadGeneration !== workspaceLoadGeneration
|| get().activeWorkspaceId !== workspaceId) return;
set((state) => ({
status: 'ready',
bootstrap: upsertSummary(state.bootstrap, workspace),
activeWorkspaceId: workspaceId,
activeConversationId: conversationId,
workspace,
conversation: null,
tasks: sortTasks(tasks),
error: null,
}));
if (!conversationId) return;
const selectionGeneration = ++conversationSelectionGeneration;
const conversation = await fetchImageWorkspaceConversation(workspaceId, conversationId);
if (loadGeneration !== workspaceLoadGeneration
|| selectionGeneration !== conversationSelectionGeneration
|| get().activeWorkspaceId !== workspaceId
|| get().activeConversationId !== conversationId) return;
applyConversation(conversation);
startTaskStream(workspaceId, conversationId);
};
const recoverRevisionConflict = async (error: unknown): Promise<never> => {
if (error instanceof ImageWorkspaceApiError
&& (error.code === 'conversation_revision_conflict'
|| error.code === 'workspace_revision_conflict')) {
await get().refreshConversation().catch(() => null);
throw new ImageWorkspaceApiError(
409,
error.code,
'设计会话已更新,内容已刷新,请重新提交',
);
}
throw error;
};
return {
status: 'idle',
bootstrap: null,
activeWorkspaceId: null,
activeConversationId: null,
workspace: null,
conversation: null,
creatingConversation: false,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
error: null,
load: () => {
if (inFlightLoad) return inFlightLoad;
set({ status: 'loading', error: null });
inFlightLoad = (async () => {
try {
const bootstrap = await fetchImageWorkspace();
const currentId = get().activeWorkspaceId;
const activeWorkspaceId = bootstrap.workspaces.some(
(workspace) => workspace.workspaceId === currentId,
)
? currentId
: bootstrap.workspaces[0]?.workspaceId ?? null;
closeTaskEventSource();
set({
status: 'ready',
bootstrap,
activeWorkspaceId,
activeConversationId: null,
workspace: null,
conversation: null,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
error: null,
});
if (activeWorkspaceId) await loadWorkspace(activeWorkspaceId);
return bootstrap;
} catch (error) {
const message = handleRequestError(error);
if (!authenticationRequired(error)) {
closeTaskEventSource();
set({
status: unavailable(error) ? 'unavailable' : 'error',
bootstrap: null,
activeWorkspaceId: null,
activeConversationId: null,
workspace: null,
conversation: null,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
error: message,
});
}
return null;
} finally {
inFlightLoad = null;
}
})();
return inFlightLoad;
},
createProject: async (title) => {
try {
const workspace = applyWorkspace(await createImageWorkspaceProject(title));
const conversationId = workspace.conversations[0]?.conversationId ?? null;
set({
activeConversationId: conversationId,
conversation: null,
tasks: [],
pendingTurn: null,
});
if (conversationId) {
const selectionGeneration = ++conversationSelectionGeneration;
const conversation = await fetchImageWorkspaceConversation(
workspace.workspaceId,
conversationId,
);
if (selectionGeneration === conversationSelectionGeneration
&& get().activeWorkspaceId === workspace.workspaceId
&& get().activeConversationId === conversationId) {
applyConversation(conversation);
startTaskStream(workspace.workspaceId, conversationId);
}
}
return workspace;
} catch (error) {
set({ error: handleRequestError(error) });
throw error;
}
},
createConversation: async (title = '新会话') => {
if (get().creatingConversation) {
throw new Error('正在创建新会话,请稍候');
}
const workspaceId = get().activeWorkspaceId;
if (!workspaceId) throw new Error('请先选择设计项目');
set({ creatingConversation: true });
try {
const conversation = await createImageWorkspaceConversation(workspaceId, title);
if (get().activeWorkspaceId !== workspaceId) return conversation;
conversationSelectionGeneration += 1;
stopTaskStream();
set({
activeConversationId: conversation.conversationId,
conversation: null,
pendingTurn: null,
taskStreamState: 'idle',
error: null,
});
applyConversation(conversation);
startTaskStream(workspaceId, conversation.conversationId);
void get().refreshWorkspace().catch(() => null);
return conversation;
} catch (error) {
set({ error: handleRequestError(error) });
throw error;
} finally {
set({ creatingConversation: false });
}
},
renameProject: async (workspaceId, title) => {
try {
return applyWorkspace(await renameImageWorkspaceProject(workspaceId, title));
} catch (error) {
set({ error: handleRequestError(error) });
throw error;
}
},
selectProject: async (workspaceId) => {
if (!get().bootstrap?.workspaces.some((item) => item.workspaceId === workspaceId)) return;
stopTaskStream();
set({
activeWorkspaceId: workspaceId,
activeConversationId: null,
workspace: null,
conversation: null,
creatingConversation: false,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
error: null,
});
const loadPromise = loadWorkspace(workspaceId);
const loadGeneration = workspaceLoadGeneration;
try {
await loadPromise;
} catch (error) {
if (loadGeneration === workspaceLoadGeneration
&& get().activeWorkspaceId === workspaceId) {
set({ error: handleRequestError(error) });
}
}
},
selectConversation: async (conversationId) => {
const state = get();
const workspaceId = state.activeWorkspaceId;
if (!workspaceId
|| !state.workspace?.conversations.some(
(item) => item.conversationId === conversationId,
)) return;
const selectionGeneration = ++conversationSelectionGeneration;
stopTaskStream();
set({
activeConversationId: conversationId,
conversation: null,
pendingTurn: null,
taskStreamState: 'idle',
error: null,
});
try {
const conversation = await fetchImageWorkspaceConversation(workspaceId, conversationId);
if (selectionGeneration !== conversationSelectionGeneration
|| get().activeWorkspaceId !== workspaceId
|| get().activeConversationId !== conversationId) return;
applyConversation(conversation);
startTaskStream(workspaceId, conversationId);
} catch (error) {
if (selectionGeneration === conversationSelectionGeneration
&& get().activeWorkspaceId === workspaceId
&& get().activeConversationId === conversationId) {
set({ error: handleRequestError(error) });
}
}
},
refreshWorkspace: async () => {
const workspaceId = get().activeWorkspaceId;
if (!workspaceId) return null;
try {
const refreshed = await fetchImageWorkspaceProject(workspaceId);
if (get().activeWorkspaceId !== workspaceId) return null;
const current = get().workspace;
if (current?.workspaceId === workspaceId
&& current.viewRevision > refreshed.viewRevision) {
return current;
}
return applyWorkspace(refreshed);
} catch (error) {
set({ error: handleRequestError(error) });
throw error;
}
},
refreshConversation: async () => {
const workspaceId = get().activeWorkspaceId;
const conversationId = get().activeConversationId;
if (!workspaceId || !conversationId) return null;
try {
const refreshed = await fetchImageWorkspaceConversation(workspaceId, conversationId);
if (get().activeWorkspaceId !== workspaceId
|| get().activeConversationId !== conversationId) return null;
const current = get().conversation;
if (current?.conversationId === conversationId
&& current.turnRevision > refreshed.turnRevision) {
return current;
}
return applyConversation(refreshed);
} catch (error) {
set({ error: handleRequestError(error) });
throw error;
}
},
refreshTasks: async () => {
const workspaceId = get().activeWorkspaceId;
if (!workspaceId) {
set({ tasks: [] });
return [];
}
try {
return await refreshWorkspaceTasks(workspaceId);
} catch (error) {
set({ error: handleRequestError(error) });
throw error;
}
},
connectTaskStream: () => {
const workspaceId = get().activeWorkspaceId;
const conversationId = get().activeConversationId;
if (workspaceId && conversationId) startTaskStream(workspaceId, conversationId);
},
disconnectTaskStream: stopTaskStream,
sendMessage: async (message, attachmentAssetIds = []) => {
const workspace = get().workspace;
const conversation = get().conversation;
const userText = message.trim();
const clientTurnId = createImageWorkspaceTurnId();
if (!workspace || !conversation) throw new Error('请先选择设计会话');
try {
set({
pendingTurn: createPendingTurn(conversation, clientTurnId, userText),
error: null,
});
const updated = attachmentAssetIds.length > 0
? await sendImageWorkspaceMessage(
workspace.workspaceId,
conversation.conversationId,
conversation.turnRevision,
userText,
clientTurnId,
attachmentAssetIds,
)
: await sendImageWorkspaceMessage(
workspace.workspaceId,
conversation.conversationId,
conversation.turnRevision,
userText,
clientTurnId,
);
if (get().activeWorkspaceId === workspace.workspaceId
&& get().activeConversationId === conversation.conversationId) {
applyConversation(updated);
await get().refreshTasks().catch(() => []);
}
return updated;
} catch (error) {
if (get().activeWorkspaceId !== workspace.workspaceId
|| get().activeConversationId !== conversation.conversationId) throw error;
const errorMessage = handleRequestError(error);
set((state) => ({
error: errorMessage,
pendingTurn: state.pendingTurn?.clientTurnId === clientTurnId
? null
: state.pendingTurn,
}));
return await recoverRevisionConflict(error);
}
},
confirmGeneration: async (quoteId) => {
const workspace = get().workspace;
const conversation = get().conversation;
if (!workspace || !conversation) throw new Error('请先选择设计会话');
const clientTurnId = createImageWorkspaceTurnId();
const requestWorkspaceLoadGeneration = workspaceLoadGeneration;
const requestConversationSelectionGeneration = conversationSelectionGeneration;
const isActiveConfirmationWorkspace = (): boolean => (
get().activeWorkspaceId === workspace.workspaceId
);
const isCurrentConfirmationConversation = (): boolean => (
isActiveConfirmationWorkspace()
&& requestWorkspaceLoadGeneration === workspaceLoadGeneration
&& requestConversationSelectionGeneration === conversationSelectionGeneration
&& get().activeConversationId === conversation.conversationId
);
try {
set({
pendingTurn: createPendingTurn(conversation, clientTurnId, '确认生成'),
error: null,
});
const updated = await confirmImageWorkspaceGeneration(
workspace.workspaceId,
conversation.conversationId,
conversation.turnRevision,
quoteId,
clientTurnId,
);
if (isCurrentConfirmationConversation()) applyConversation(updated);
if (!isActiveConfirmationWorkspace()) return updated;
let confirmedTaskVisible = get().tasks.some((task) => task.quoteId === quoteId);
for (
let attempt = 0;
!confirmedTaskVisible && attempt < CONFIRMED_TASK_RECONCILE_ATTEMPTS;
attempt += 1
) {
if (!isActiveConfirmationWorkspace()) return updated;
const refreshed = await refreshWorkspaceTasks(workspace.workspaceId);
if (!isActiveConfirmationWorkspace()) return updated;
confirmedTaskVisible = refreshed.some((task) => task.quoteId === quoteId)
|| get().tasks.some((task) => task.quoteId === quoteId);
if (!confirmedTaskVisible && attempt + 1 < CONFIRMED_TASK_RECONCILE_ATTEMPTS) {
await new Promise<void>((resolve) => {
setTimeout(resolve, CONFIRMED_TASK_RECONCILE_INTERVAL_MS);
});
}
}
if (!confirmedTaskVisible) {
throw new ImageWorkspaceApiError(
502,
'generation_task_not_visible',
'生成已确认,但任务列表同步超时。请刷新项目查看,不要重复确认。',
);
}
return updated;
} catch (error) {
if (!isActiveConfirmationWorkspace()) throw error;
const taskVisibilityAlreadyReconciled = error instanceof ImageWorkspaceApiError
&& error.code === 'generation_task_not_visible';
let confirmedTaskVisible = get().tasks.some((task) => task.quoteId === quoteId);
if (!taskVisibilityAlreadyReconciled && !confirmedTaskVisible) {
const refreshed = await refreshWorkspaceTasks(workspace.workspaceId).catch(() => []);
if (!isActiveConfirmationWorkspace()) throw error;
confirmedTaskVisible = refreshed.some((task) => task.quoteId === quoteId)
|| get().tasks.some((task) => task.quoteId === quoteId);
}
if (confirmedTaskVisible) {
if (!isCurrentConfirmationConversation()) return conversation;
const refreshedConversation = await fetchImageWorkspaceConversation(
workspace.workspaceId,
conversation.conversationId,
).catch(() => null);
if (!isCurrentConfirmationConversation()) return conversation;
const recoveredConversation = refreshedConversation
? applyConversation(refreshedConversation)
: get().conversation;
if (recoveredConversation?.conversationId === conversation.conversationId) {
set((state) => ({
error: null,
pendingTurn: state.pendingTurn?.clientTurnId === clientTurnId
? null
: state.pendingTurn,
}));
return recoveredConversation;
}
}
if (!isCurrentConfirmationConversation()) throw error;
const errorMessage = handleRequestError(error);
set((state) => ({
error: errorMessage,
pendingTurn: state.pendingTurn?.clientTurnId === clientTurnId
? null
: state.pendingTurn,
}));
return await recoverRevisionConflict(error);
}
},
reset: () => {
inFlightLoad = null;
workspaceLoadGeneration += 1;
conversationSelectionGeneration += 1;
closeTaskEventSource();
set({
status: 'idle',
bootstrap: null,
activeWorkspaceId: null,
activeConversationId: null,
workspace: null,
conversation: null,
creatingConversation: false,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
error: null,
});
},
};
});