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

1394 lines
50 KiB
TypeScript

import { create } from 'zustand';
import {
confirmImageWorkspaceGeneration,
createImageWorkspaceConversation,
createImageWorkspaceTurnId,
createImageWorkspaceProject,
deleteImageWorkspaceProject,
fetchImageWorkspace,
fetchImageWorkspaceConversation,
fetchImageWorkspaceProject,
fetchImageWorkspaceTasks,
ImageWorkspaceApiError,
openImageWorkspaceTaskEvents,
renameImageWorkspaceProject,
sendImageWorkspaceMessage,
updateImageWorkspaceGenerationQuote,
} from '@/lib/image-workspace';
import { useAuthStore } from '@/stores/auth';
import { setDesktopBackgroundLease } from '@/lib/host-api';
import {
IMAGE_WORKSPACE_UNAVAILABLE_CODE,
type DesignAssistantDeltaEvent,
type DesignConversation,
type DesignConversationSnapshotEvent,
type DesignDeleteWorkspaceResult,
type DesignGenerationTask,
type DesignGenerationParameters,
type DesignGenerationQuote,
type DesignGenerationTaskUpdatedEvent,
type DesignMessage,
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;
deletingWorkspaceId: string | null;
tasks: DesignGenerationTask[];
pendingTurn: PendingDesignTurn | null;
loadingOlderMessages: boolean;
olderMessagesError: string | 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>;
deleteProject: (workspaceId: string) => Promise<DesignDeleteWorkspaceResult>;
selectProject: (workspaceId: string) => Promise<void>;
selectConversation: (conversationId: string) => Promise<void>;
refreshWorkspace: () => Promise<DesignWorkspace | null>;
refreshConversation: () => Promise<DesignConversation | null>;
loadOlderMessages: () => Promise<DesignConversation | null>;
refreshTasks: () => Promise<DesignGenerationTask[]>;
connectTaskStream: () => void;
markTaskStreamNeedsReconciliation: () => void;
disconnectTaskStream: () => void;
sendMessage: (message: string, attachmentAssetIds?: string[]) => Promise<DesignConversation>;
updateGenerationQuote: (
quoteId: string,
finalPrompt: string,
generationParameters: DesignGenerationParameters,
) => Promise<DesignGenerationQuote>;
confirmGeneration: (
quoteId: string,
finalPrompt: string,
generationParameters: DesignGenerationParameters,
) => 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;
const pendingTaskEventConnections = new Map<string, symbol>();
let taskFallbackTimer: ReturnType<typeof setInterval> | null = null;
const taskEventRevisions = new Map<string, number>();
let workspaceLoadGeneration = 0;
let conversationSelectionGeneration = 0;
let projectDeletionGeneration = 0;
let generationQuoteUpdateSequence = 0;
let reconcileTaskStateOnNextConnect = false;
function updateBackgroundLease(input: {
id: string;
kind: string;
active: boolean;
}): void {
try {
void Promise.resolve(setDesktopBackgroundLease(input)).catch(() => undefined);
} catch {
// Renderer-only tests and embedded previews may not expose lifecycle IPC.
}
}
function taskRevisionKey(workspaceId: string, taskId: string): string {
return `${workspaceId}:${taskId}`;
}
function taskStreamKey(workspaceId: string, conversationId: string): string {
return `${workspaceId}:${conversationId}`;
}
function stopFallbackPolling(): void {
if (taskFallbackTimer !== null) clearInterval(taskFallbackTimer);
taskFallbackTimer = null;
}
function closeTaskEventSource(): void {
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 mostRecentlyUpdatedWorkspace(
workspaces: DesignWorkspaceSummary[],
): DesignWorkspaceSummary | null {
return workspaces.reduce<DesignWorkspaceSummary | null>((latest, workspace) => {
if (!latest || workspace.updatedAt > latest.updatedAt) return workspace;
return latest;
}, null);
}
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(),
};
}
function mergePendingUserMessage(
conversation: DesignConversation,
pendingTurn: PendingDesignTurn | null,
): DesignConversation {
if (!pendingTurn
|| pendingTurn.userText === null
|| pendingTurn.workspaceId !== conversation.workspaceId
|| pendingTurn.conversationId !== conversation.conversationId
|| conversation.turnRevision < pendingTurn.turnRevision) {
return conversation;
}
const alreadyPersisted = conversation.messages.some((message) => (
message.role === 'user'
&& message.turnRevision === pendingTurn.turnRevision
&& message.text === pendingTurn.userText
));
if (alreadyPersisted) return conversation;
const userMessage: DesignMessage = {
id: `${pendingTurn.clientTurnId}:user`,
role: 'user',
kind: 'user',
text: pendingTurn.userText,
quickReplies: [],
generationQuote: null,
turnRevision: pendingTurn.turnRevision,
createdAt: pendingTurn.createdAt,
};
const insertionIndex = conversation.messages.findIndex(
(message) => message.turnRevision >= pendingTurn.turnRevision,
);
const messages = [...conversation.messages];
messages.splice(insertionIndex < 0 ? messages.length : insertionIndex, 0, userMessage);
return { ...conversation, messages };
}
function mergeConversationMessagePages(
current: DesignConversation | null,
incoming: DesignConversation,
source: 'latest' | 'older',
): DesignConversation {
if (!current
|| current.conversationId !== incoming.conversationId
|| current.workspaceId !== incoming.workspaceId) {
return incoming;
}
const messagesById = new Map<string, DesignMessage>();
const messageSources = source === 'older'
? [incoming.messages, current.messages]
: [current.messages, incoming.messages];
for (const messages of messageSources) {
for (const message of messages) messagesById.set(message.id, message);
}
const messages = [...messagesById.values()].sort((left, right) => (
left.turnRevision - right.turnRevision
|| (left.role === right.role ? left.id.localeCompare(right.id) : left.role === 'user' ? -1 : 1)
|| left.createdAt.localeCompare(right.createdAt)
));
return {
...incoming,
messages,
messagePage: source === 'older'
? incoming.messagePage
: current.messagePage ?? incoming.messagePage,
};
}
function replaceGenerationQuote(
conversation: DesignConversation,
previousQuoteId: string,
quote: DesignGenerationQuote,
): DesignConversation {
let changed = false;
const messages = conversation.messages.map((message) => {
if (message.generationQuote?.quoteId !== previousQuoteId) return message;
changed = true;
return { ...message, generationQuote: quote };
});
return changed ? { ...conversation, messages } : conversation;
}
export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) => {
const pendingAssistantDeltas: DesignAssistantDeltaEvent[] = [];
let assistantDeltaFlushTimer: ReturnType<typeof setTimeout> | null = null;
const applyAssistantDelta = (
state: ImageWorkspaceState,
delta: DesignAssistantDeltaEvent,
): ImageWorkspaceState => {
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 {
...state,
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 {
...state,
pendingTurn: {
...pending,
assistantText: `${pending.assistantText}${delta.delta}`,
lastChunkIndex: delta.chunkIndex,
},
};
};
const flushAssistantDeltas = () => {
assistantDeltaFlushTimer = null;
if (pendingAssistantDeltas.length === 0) return;
const queued = pendingAssistantDeltas.splice(0, pendingAssistantDeltas.length);
set((state) => queued.reduce(applyAssistantDelta, state));
};
const queueAssistantDelta = (delta: DesignAssistantDeltaEvent) => {
pendingAssistantDeltas.push(delta);
if (assistantDeltaFlushTimer === null) {
assistantDeltaFlushTimer = setTimeout(flushAssistantDeltas, 50);
}
};
const stopTaskStream = () => {
pendingAssistantDeltas.length = 0;
if (assistantDeltaFlushTimer !== null) clearTimeout(assistantDeltaFlushTimer);
assistantDeltaFlushTimer = null;
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();
activeTaskEventWorkspaceId = workspaceId;
activeTaskEventConversationId = conversationId;
set({ taskStreamState: 'connecting' });
const connectionKey = taskStreamKey(workspaceId, conversationId);
if (pendingTaskEventConnections.has(connectionKey)) return;
const connectionAttempt = Symbol(connectionKey);
pendingTaskEventConnections.set(connectionKey, connectionAttempt);
void openImageWorkspaceTaskEvents(workspaceId, conversationId).then((source) => {
if (pendingTaskEventConnections.get(connectionKey) !== connectionAttempt) {
source.close();
return;
}
pendingTaskEventConnections.delete(connectionKey);
if (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;
queueAssistantDelta(delta);
});
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 resolvedConversation = mergeConversationMessagePages(
state.conversation,
mergePendingUserMessage(conversation, state.pendingTurn),
'latest',
);
const workspace = upsertConversation(
state.workspace
? {
...state.workspace,
viewRevision: Math.max(
state.workspace.viewRevision,
snapshot.workspaceViewRevision,
),
}
: null,
resolvedConversation,
);
const bootstrap = workspace?.workspaceId === snapshot.workspaceId
? upsertSummary(state.bootstrap, workspace)
: state.bootstrap;
const pendingTurn = state.pendingTurn?.workspaceId === snapshot.workspaceId
&& state.pendingTurn.conversationId === snapshot.conversationId
&& resolvedConversation.turnRevision >= state.pendingTurn.turnRevision
? null
: state.pendingTurn;
return {
tasks: sortTasks([...tasksById.values()]),
workspace,
conversation: resolvedConversation,
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 (pendingTaskEventConnections.get(connectionKey) !== connectionAttempt) return;
pendingTaskEventConnections.delete(connectionKey);
if (get().activeWorkspaceId !== workspaceId
|| get().activeConversationId !== conversationId) return;
set({ taskStreamState: 'degraded' });
startFallbackPolling();
});
};
const handleRequestError = (error: unknown): string => {
const message = messageOf(error);
if (authenticationRequired(error)) {
projectDeletionGeneration += 1;
stopTaskStream();
useAuthStore.getState().invalidateSession();
set({
status: 'auth-required',
bootstrap: null,
activeWorkspaceId: null,
activeConversationId: null,
workspace: null,
conversation: null,
creatingConversation: false,
deletingWorkspaceId: null,
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,
source: 'latest' | 'older' = 'latest',
): 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 resolvedConversation = mergeConversationMessagePages(
state.conversation,
mergePendingUserMessage(conversation, state.pendingTurn),
source,
);
applied = resolvedConversation;
const pendingTurn = state.pendingTurn?.workspaceId === resolvedConversation.workspaceId
&& state.pendingTurn.conversationId === resolvedConversation.conversationId
&& resolvedConversation.turnRevision >= state.pendingTurn.turnRevision
? null
: state.pendingTurn;
return {
workspace: upsertConversation(state.workspace, resolvedConversation),
activeConversationId: resolvedConversation.conversationId,
conversation: resolvedConversation,
pendingTurn,
loadingOlderMessages: false,
olderMessagesError: null,
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;
set((state) => ({
status: 'ready',
bootstrap: upsertSummary(state.bootstrap, workspace),
activeWorkspaceId: workspaceId,
activeConversationId: conversationId,
workspace,
conversation: null,
tasks: [],
pendingTurn: null,
loadingOlderMessages: false,
olderMessagesError: null,
error: null,
}));
void fetchImageWorkspaceTasks(workspaceId).then((tasks) => {
if (loadGeneration !== workspaceLoadGeneration
|| get().activeWorkspaceId !== workspaceId) return;
set({ tasks: sortTasks(tasks) });
}).catch(() => undefined);
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,
deletingWorkspaceId: null,
tasks: [],
pendingTurn: null,
loadingOlderMessages: false,
olderMessagesError: 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;
stopTaskStream();
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)) {
stopTaskStream();
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,
loadingOlderMessages: false,
olderMessagesError: 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;
}
},
deleteProject: async (workspaceId) => {
const state = get();
if (state.deletingWorkspaceId) {
const error = new Error('正在删除设计项目,请稍候');
set({ error: error.message });
throw error;
}
const deletionGeneration = ++projectDeletionGeneration;
const isCurrentDeletion = (): boolean => (
deletionGeneration === projectDeletionGeneration
&& get().deletingWorkspaceId === workspaceId
);
set({ deletingWorkspaceId: workspaceId, error: null });
try {
const result = await deleteImageWorkspaceProject(workspaceId);
if (!isCurrentDeletion()) return result;
const current = get();
const remainingWorkspaces = current.bootstrap?.workspaces.filter(
(workspace) => workspace.workspaceId !== workspaceId,
) ?? [];
const bootstrap = current.bootstrap
? { ...current.bootstrap, workspaces: remainingWorkspaces }
: null;
if (current.activeWorkspaceId !== workspaceId) {
set({ bootstrap, error: null });
return result;
}
workspaceLoadGeneration += 1;
conversationSelectionGeneration += 1;
reconcileTaskStateOnNextConnect = false;
stopTaskStream();
const nextWorkspaceId = mostRecentlyUpdatedWorkspace(remainingWorkspaces)?.workspaceId
?? null;
set({
status: 'ready',
bootstrap,
activeWorkspaceId: nextWorkspaceId,
activeConversationId: null,
workspace: null,
conversation: null,
creatingConversation: false,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
error: null,
});
if (nextWorkspaceId) {
try {
await loadWorkspace(nextWorkspaceId);
} catch (error) {
if (get().activeWorkspaceId === nextWorkspaceId) {
set({ error: handleRequestError(error) });
}
}
}
return result;
} catch (error) {
if (isCurrentDeletion()) set({ error: handleRequestError(error) });
throw error;
} finally {
if (isCurrentDeletion()) set({ deletingWorkspaceId: null });
}
},
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,
loadingOlderMessages: false,
olderMessagesError: 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,
loadingOlderMessages: false,
olderMessagesError: 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;
}
},
loadOlderMessages: async () => {
const workspaceId = get().activeWorkspaceId;
const conversation = get().conversation;
const before = conversation?.messagePage?.nextBefore;
if (!workspaceId || !conversation || !conversation.messagePage?.hasOlder || !before) {
return conversation ?? null;
}
const conversationId = conversation.conversationId;
const selectionGeneration = conversationSelectionGeneration;
set({ loadingOlderMessages: true, olderMessagesError: null });
try {
const older = await fetchImageWorkspaceConversation(workspaceId, conversationId, before);
if (selectionGeneration !== conversationSelectionGeneration
|| get().activeWorkspaceId !== workspaceId
|| get().activeConversationId !== conversationId) return null;
return applyConversation(older, 'older');
} catch (error) {
if (selectionGeneration === conversationSelectionGeneration
&& get().activeWorkspaceId === workspaceId
&& get().activeConversationId === conversationId) {
set({
loadingOlderMessages: false,
olderMessagesError: handleRequestError(error),
});
}
return null;
}
},
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;
const shouldReconcile = reconcileTaskStateOnNextConnect;
reconcileTaskStateOnNextConnect = false;
if (shouldReconcile && get().taskStreamState === 'degraded') {
closeTaskEventSource();
}
if (workspaceId && conversationId) startTaskStream(workspaceId, conversationId);
if (!workspaceId || !conversationId || !shouldReconcile) return;
void Promise.all([
get().refreshConversation(),
get().refreshTasks(),
]).catch(() => undefined);
},
markTaskStreamNeedsReconciliation: () => {
reconcileTaskStateOnNextConnect = true;
},
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('请先选择设计会话');
const leaseId = `canvas:${workspace.workspaceId}:${clientTurnId}`;
updateBackgroundLease({ id: leaseId, kind: 'canvas-generation', active: true });
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);
} finally {
updateBackgroundLease({ id: leaseId, kind: 'canvas-generation', active: false });
}
},
updateGenerationQuote: async (quoteId, finalPrompt, generationParameters) => {
const workspace = get().workspace;
const conversation = get().conversation;
if (!workspace || !conversation) throw new Error('请先选择设计会话');
const updateSequence = ++generationQuoteUpdateSequence;
const requestedWorkspaceId = workspace.workspaceId;
const requestedConversationId = conversation.conversationId;
try {
const updatedQuote = await updateImageWorkspaceGenerationQuote(
requestedWorkspaceId,
quoteId,
finalPrompt,
generationParameters,
);
if (updateSequence !== generationQuoteUpdateSequence) return updatedQuote;
if (get().activeWorkspaceId !== requestedWorkspaceId
|| get().activeConversationId !== requestedConversationId) {
return updatedQuote;
}
set((state) => {
if (!state.conversation
|| state.conversation.conversationId !== requestedConversationId) return state;
const conversationWithQuote = replaceGenerationQuote(
state.conversation,
quoteId,
updatedQuote,
);
if (conversationWithQuote === state.conversation) return state;
const workspaceWithQuote = upsertConversation(
state.workspace,
conversationWithQuote,
);
return {
conversation: conversationWithQuote,
workspace: workspaceWithQuote,
bootstrap: workspaceWithQuote
? upsertSummary(state.bootstrap, workspaceWithQuote)
: state.bootstrap,
error: null,
};
});
return updatedQuote;
} catch (error) {
if (updateSequence === generationQuoteUpdateSequence
&& get().activeWorkspaceId === requestedWorkspaceId
&& get().activeConversationId === requestedConversationId) {
set({ error: handleRequestError(error) });
}
throw error;
}
},
confirmGeneration: async (quoteId, finalPrompt, generationParameters) => {
const workspace = get().workspace;
const conversation = get().conversation;
if (!workspace || !conversation) throw new Error('请先选择设计会话');
const clientTurnId = createImageWorkspaceTurnId();
const leaseId = `canvas:${workspace.workspaceId}:${clientTurnId}`;
updateBackgroundLease({ id: leaseId, kind: 'canvas-generation', active: true });
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 = finalPrompt === undefined || generationParameters === undefined
? await confirmImageWorkspaceGeneration(
workspace.workspaceId,
conversation.conversationId,
conversation.turnRevision,
quoteId,
clientTurnId,
)
: await confirmImageWorkspaceGeneration(
workspace.workspaceId,
conversation.conversationId,
conversation.turnRevision,
quoteId,
clientTurnId,
finalPrompt,
generationParameters,
);
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);
} finally {
updateBackgroundLease({ id: leaseId, kind: 'canvas-generation', active: false });
}
},
reset: () => {
inFlightLoad = null;
workspaceLoadGeneration += 1;
conversationSelectionGeneration += 1;
projectDeletionGeneration += 1;
generationQuoteUpdateSequence += 1;
reconcileTaskStateOnNextConnect = false;
stopTaskStream();
pendingTaskEventConnections.clear();
set({
status: 'idle',
bootstrap: null,
activeWorkspaceId: null,
activeConversationId: null,
workspace: null,
conversation: null,
creatingConversation: false,
deletingWorkspaceId: null,
tasks: [],
pendingTurn: null,
loadingOlderMessages: false,
olderMessagesError: null,
taskStreamState: 'idle',
error: null,
});
},
};
});