1394 lines
50 KiB
TypeScript
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,
|
|
});
|
|
},
|
|
};
|
|
});
|