1011 lines
36 KiB
TypeScript
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,
|
|
});
|
|
},
|
|
};
|
|
});
|