feat: 完善图像工作区与创作工具体验
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-16 14:08:02 +08:00
parent bfcb88cfef
commit 26b52d76e3
92 changed files with 16678 additions and 2975 deletions

View File

@@ -0,0 +1,25 @@
import { create } from 'zustand';
export type PendingPromptMuseumPrompt = {
promptId: string;
title: string;
prompt: string;
};
type ImagePromptMuseumState = {
pendingPrompt: PendingPromptMuseumPrompt | null;
setPendingPrompt: (prompt: PendingPromptMuseumPrompt) => void;
consumePendingPrompt: () => PendingPromptMuseumPrompt | null;
clearPendingPrompt: () => void;
};
export const useImagePromptMuseumStore = create<ImagePromptMuseumState>((set, get) => ({
pendingPrompt: null,
setPendingPrompt: (prompt) => set({ pendingPrompt: prompt }),
consumePendingPrompt: () => {
const prompt = get().pendingPrompt;
if (prompt) set({ pendingPrompt: null });
return prompt;
},
clearPendingPrompt: () => set({ pendingPrompt: null }),
}));

View File

@@ -4,6 +4,7 @@ import {
createImageWorkspaceConversation,
createImageWorkspaceTurnId,
createImageWorkspaceProject,
deleteImageWorkspaceProject,
fetchImageWorkspace,
fetchImageWorkspaceConversation,
fetchImageWorkspaceProject,
@@ -12,6 +13,7 @@ import {
openImageWorkspaceTaskEvents,
renameImageWorkspaceProject,
sendImageWorkspaceMessage,
updateImageWorkspaceGenerationQuote,
} from '@/lib/image-workspace';
import { useAuthStore } from '@/stores/auth';
import {
@@ -19,8 +21,12 @@ import {
type DesignAssistantDeltaEvent,
type DesignConversation,
type DesignConversationSnapshotEvent,
type DesignDeleteWorkspaceResult,
type DesignGenerationTask,
type DesignGenerationParameters,
type DesignGenerationQuote,
type DesignGenerationTaskUpdatedEvent,
type DesignMessage,
type DesignWorkspace,
type DesignWorkspaceBootstrap,
type DesignWorkspaceSummary,
@@ -55,6 +61,7 @@ type ImageWorkspaceState = {
workspace: DesignWorkspace | null;
conversation: DesignConversation | null;
creatingConversation: boolean;
deletingWorkspaceId: string | null;
tasks: DesignGenerationTask[];
pendingTurn: PendingDesignTurn | null;
taskStreamState: ImageWorkspaceTaskStreamState;
@@ -63,15 +70,26 @@ type ImageWorkspaceState = {
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>;
refreshTasks: () => Promise<DesignGenerationTask[]>;
connectTaskStream: () => void;
markTaskStreamNeedsReconciliation: () => void;
disconnectTaskStream: () => void;
sendMessage: (message: string, attachmentAssetIds?: string[]) => Promise<DesignConversation>;
confirmGeneration: (quoteId: string) => Promise<DesignConversation>;
updateGenerationQuote: (
quoteId: string,
finalPrompt: string,
generationParameters: DesignGenerationParameters,
) => Promise<DesignGenerationQuote>;
confirmGeneration: (
quoteId: string,
finalPrompt: string,
generationParameters: DesignGenerationParameters,
) => Promise<DesignConversation>;
reset: () => void;
};
@@ -87,6 +105,9 @@ 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 taskRevisionKey(workspaceId: string, taskId: string): string {
return `${workspaceId}:${taskId}`;
@@ -188,6 +209,15 @@ function sortTasks(tasks: DesignGenerationTask[]): DesignGenerationTask[] {
));
}
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[],
@@ -266,6 +296,85 @@ function createPendingTurn(
};
}
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 mergeUserMessagesFromCurrentConversation(
current: DesignConversation | null,
incoming: DesignConversation,
): DesignConversation {
if (!current
|| current.conversationId !== incoming.conversationId
|| current.workspaceId !== incoming.workspaceId) {
return incoming;
}
const missingUserMessages = current.messages.filter((message) => (
message.role === 'user'
&& !incoming.messages.some((candidate) => (
candidate.role === 'user'
&& candidate.turnRevision === message.turnRevision
&& candidate.text === message.text
))
));
if (missingUserMessages.length === 0) return incoming;
const messages = [...incoming.messages];
for (const userMessage of missingUserMessages) {
const insertionIndex = messages.findIndex(
(message) => message.turnRevision >= userMessage.turnRevision,
);
messages.splice(insertionIndex < 0 ? messages.length : insertionIndex, 0, userMessage);
}
return { ...incoming, messages };
}
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 stopTaskStream = () => {
closeTaskEventSource();
@@ -369,6 +478,10 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|| snapshot.conversation.turnRevision >= state.conversation.turnRevision
? snapshot.conversation
: state.conversation;
const resolvedConversation = mergeUserMessagesFromCurrentConversation(
state.conversation,
mergePendingUserMessage(conversation, state.pendingTurn),
);
const workspace = upsertConversation(
state.workspace
? {
@@ -379,20 +492,20 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
),
}
: null,
conversation,
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
&& snapshot.conversation.turnRevision >= state.pendingTurn.turnRevision
&& resolvedConversation.turnRevision >= state.pendingTurn.turnRevision
? null
: state.pendingTurn;
return {
tasks: sortTasks([...tasksById.values()]),
workspace,
conversation,
conversation: resolvedConversation,
bootstrap,
pendingTurn,
};
@@ -465,6 +578,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
const handleRequestError = (error: unknown): string => {
const message = messageOf(error);
if (authenticationRequired(error)) {
projectDeletionGeneration += 1;
closeTaskEventSource();
useAuthStore.getState().invalidateSession();
set({
@@ -475,6 +589,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
workspace: null,
conversation: null,
creatingConversation: false,
deletingWorkspaceId: null,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
@@ -523,15 +638,20 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
applied = state.conversation;
return state;
}
const pendingTurn = state.pendingTurn?.workspaceId === conversation.workspaceId
&& state.pendingTurn.conversationId === conversation.conversationId
&& conversation.turnRevision >= state.pendingTurn.turnRevision
const resolvedConversation = mergeUserMessagesFromCurrentConversation(
state.conversation,
mergePendingUserMessage(conversation, state.pendingTurn),
);
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, conversation),
activeConversationId: conversation.conversationId,
conversation,
workspace: upsertConversation(state.workspace, resolvedConversation),
activeConversationId: resolvedConversation.conversationId,
conversation: resolvedConversation,
pendingTurn,
error: null,
};
@@ -597,6 +717,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
workspace: null,
conversation: null,
creatingConversation: false,
deletingWorkspaceId: null,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
@@ -724,6 +845,72 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
}
},
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;
closeTaskEventSource();
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();
@@ -837,7 +1024,21 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
connectTaskStream: () => {
const workspaceId = get().activeWorkspaceId;
const conversationId = get().activeConversationId;
const shouldReconcile = reconcileTaskStateOnNextConnect;
reconcileTaskStateOnNextConnect = false;
if (shouldReconcile && get().taskStreamState !== 'connected') {
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,
@@ -889,7 +1090,59 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
}
},
confirmGeneration: async (quoteId) => {
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('请先选择设计会话');
@@ -910,13 +1163,23 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
pendingTurn: createPendingTurn(conversation, clientTurnId, '确认生成'),
error: null,
});
const updated = await confirmImageWorkspaceGeneration(
workspace.workspaceId,
conversation.conversationId,
conversation.turnRevision,
quoteId,
clientTurnId,
);
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);
@@ -991,6 +1254,9 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
inFlightLoad = null;
workspaceLoadGeneration += 1;
conversationSelectionGeneration += 1;
projectDeletionGeneration += 1;
generationQuoteUpdateSequence += 1;
reconcileTaskStateOnNextConnect = false;
closeTaskEventSource();
set({
status: 'idle',
@@ -1000,6 +1266,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
workspace: null,
conversation: null,
creatingConversation: false,
deletingWorkspaceId: null,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',

View File

@@ -92,7 +92,7 @@ function pushSyncedPreferences(preferences: UserSyncPreferences): void {
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
(set, get) => ({
...defaultSettings,
init: async () => {
@@ -113,18 +113,16 @@ export const useSettingsStore = create<SettingsState>()(
// report the current administrator session.
}
const resolvedLanguage = settingsWithoutDevMode.language
? resolveSupportedLanguage(settingsWithoutDevMode.language)
: undefined;
const resolvedLanguage = resolveSupportedLanguage(
settingsWithoutDevMode.language ?? get().language,
);
set((state) => ({
...state,
...settingsWithoutDevMode,
devModeUnlocked: sessionUnlocked,
...(resolvedLanguage ? { language: resolvedLanguage } : {}),
language: resolvedLanguage,
}));
if (resolvedLanguage) {
i18n.changeLanguage(resolvedLanguage);
}
i18n.changeLanguage(resolvedLanguage);
},
setTheme: (theme) => {