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

@@ -8,7 +8,12 @@ import type {
DesignConfirmGenerationInput,
DesignCreateConversationInput,
DesignCreateWorkspaceInput,
DesignDeleteWorkspaceResult,
DesignGenerationOption,
DesignGenerationOptions,
DesignGenerationParameters,
DesignGenerationQuote,
DesignGenerationQuoteUpdateInput,
DesignGenerationTask,
DesignMessage,
DesignMedium,
@@ -28,6 +33,224 @@ const LEGACY_LOCAL_WORKSPACE_FILE = 'design-workspace-v2.json';
const MAX_PROJECT_NAME_LENGTH = 80;
const MAX_MESSAGE_LENGTH = 4_000;
const LOCAL_GENERATION_OPTIONS: Record<DesignMedium, DesignGenerationOptions> = {
image: {
models: [{
value: 'local-image-preview',
label: '本地图片预览',
default: true,
disabled: false,
multiplier: 1,
}],
resolutions: [{
value: '1024x1024',
label: '1024 × 1024',
default: true,
disabled: false,
multiplier: 1,
}],
durations: [],
aspectRatios: [
{
value: '1:1',
label: '1:1',
default: true,
disabled: false,
multiplier: 1,
},
{
value: '16:9',
label: '16:9',
default: false,
disabled: false,
multiplier: 1,
},
],
},
video: {
models: [{
value: 'local-video-preview',
label: '本地视频预览',
default: true,
disabled: false,
multiplier: 1,
}],
resolutions: [
{
value: '720P',
label: '720P',
default: false,
disabled: false,
multiplier: 0.8,
},
{
value: '1080P',
label: '1080P',
default: true,
disabled: false,
multiplier: 1,
},
],
durations: [
{
value: 4,
label: '4 秒',
default: false,
disabled: false,
multiplier: 0.8,
},
{
value: 6,
label: '6 秒',
default: true,
disabled: false,
multiplier: 1,
},
{
value: 8,
label: '8 秒',
default: false,
disabled: true,
multiplier: 1.3,
},
],
aspectRatios: [
{
value: '16:9',
label: '16:9',
default: true,
disabled: false,
multiplier: 1,
},
{
value: '9:16',
label: '9:16',
default: false,
disabled: false,
multiplier: 1,
},
],
},
};
const LOCAL_PRICING_SCHEMA = 'local-design-pricing-v1';
function defaultOption<TValue extends string | number>(
options: DesignGenerationOption<TValue>[],
): DesignGenerationOption<TValue> {
return options.find((option) => option.default && !option.disabled)
?? options.find((option) => !option.disabled)
?? options[0]!;
}
function localGenerationParameters(medium: DesignMedium): DesignGenerationParameters {
const options = LOCAL_GENERATION_OPTIONS[medium];
return {
model: defaultOption(options.models).value,
resolution: defaultOption(options.resolutions).value,
aspectRatio: defaultOption(options.aspectRatios).value,
durationSeconds: options.durations.length > 0
? defaultOption(options.durations).value
: null,
};
}
function localGenerationPricing(
medium: DesignMedium,
parameters: DesignGenerationParameters,
): number {
const options = LOCAL_GENERATION_OPTIONS[medium];
const model = options.models.find((option) => option.value === parameters.model);
const resolution = options.resolutions.find((option) => option.value === parameters.resolution);
const aspectRatio = options.aspectRatios.find((option) => option.value === parameters.aspectRatio);
const duration = parameters.durationSeconds === null
? null
: options.durations.find((option) => option.value === parameters.durationSeconds);
if (!model || model.disabled || !resolution || resolution.disabled
|| !aspectRatio || aspectRatio.disabled
|| (options.durations.length > 0 && (!duration || duration.disabled))
|| (options.durations.length === 0 && parameters.durationSeconds !== null)) {
throw new LocalImageWorkspaceError(
422,
'generation_option_invalid',
'当前生成参数不可用,请重新选择',
);
}
const baseAmount = medium === 'video' ? 2 : 1;
return Math.ceil(baseAmount * model.multiplier * resolution.multiplier * (duration?.multiplier ?? 1));
}
function createLocalQuote(
medium: DesignMedium,
briefVersion: number,
briefSummary: string,
finalPrompt: string,
quoteId: string,
expiresAt: string,
): DesignGenerationQuote {
const generationParameters = localGenerationParameters(medium);
const amount = localGenerationPricing(medium, generationParameters);
return {
quoteId,
status: 'active',
medium,
briefVersion,
briefSummary,
finalPrompt,
promptMode: 'guided',
generationParameters,
generationOptions: clone(LOCAL_GENERATION_OPTIONS[medium]),
pricing: {
schema: LOCAL_PRICING_SCHEMA,
amount,
rounding: 'ceil',
},
quotedDesignPoints: amount,
expiresAt,
};
}
function sameGenerationParameters(
left: DesignGenerationParameters,
right: DesignGenerationParameters,
): boolean {
return left.model === right.model
&& left.resolution === right.resolution
&& left.aspectRatio === right.aspectRatio
&& left.durationSeconds === right.durationSeconds;
}
function normalizePersistedGenerationQuotes(
state: PersistedImageWorkspace,
): PersistedImageWorkspace {
for (const conversations of Object.values(state.conversationsByWorkspaceId)) {
for (const conversation of conversations) {
for (const message of conversation.messages) {
const quote = message.generationQuote;
if (!quote || (quote.finalPrompt !== undefined
&& quote.generationParameters?.aspectRatio !== undefined
&& quote.generationOptions?.aspectRatios !== undefined
&& quote.pricing)) continue;
const finalPrompt = quote.finalPrompt ?? quote.briefSummary;
const normalized = createLocalQuote(
quote.medium,
quote.briefVersion,
quote.briefSummary,
finalPrompt,
quote.quoteId,
quote.expiresAt,
);
Object.assign(quote, {
...normalized,
status: quote.status,
quotedDesignPoints: quote.quotedDesignPoints,
});
}
}
}
return state;
}
const LOCAL_CAPABILITIES: DesignCapabilities = {
conversation: true,
generation: true,
@@ -272,6 +495,25 @@ export class LocalImageWorkspace implements DesignWorkspaceModule {
});
}
deleteWorkspace(workspaceId: string): Promise<DesignDeleteWorkspaceResult> {
return this.enqueue(async () => {
const state = await this.load();
this.requireWorkspace(state, workspaceId);
const nextState = clone(state);
nextState.workspaces = nextState.workspaces.filter(
(workspace) => workspace.workspaceId !== workspaceId,
);
delete nextState.conversationsByWorkspaceId[workspaceId];
delete nextState.tasksByWorkspaceId[workspaceId];
for (const [assetId, asset] of Object.entries(nextState.assetsById)) {
if (asset.workspaceId === workspaceId) delete nextState.assetsById[assetId];
}
await this.persist(nextState);
return { workspaceId, deleted: true };
});
}
createConversation(input: DesignCreateConversationInput): Promise<DesignConversation> {
return this.mutateConversation((state) => {
const workspace = this.requireWorkspace(state, input.workspaceId);
@@ -337,15 +579,14 @@ export class LocalImageWorkspace implements DesignWorkspaceModule {
const timestamp = this.now().toISOString();
const nextRevision = conversation.turnRevision + 1;
const medium = detectMedium(message);
const quote: DesignGenerationQuote = {
quoteId: `local-quote-${this.createId()}`,
status: 'active',
const quote = createLocalQuote(
medium,
briefVersion: nextRevision,
briefSummary: message,
quotedDesignPoints: medium === 'video' ? 2 : 1,
expiresAt: new Date(this.now().getTime() + 15 * 60 * 1000).toISOString(),
};
nextRevision,
message,
message,
`local-quote-${this.createId()}`,
new Date(this.now().getTime() + 15 * 60 * 1000).toISOString(),
);
conversation.messages.push(
{
id: `local-message-${this.createId()}`,
@@ -389,6 +630,46 @@ export class LocalImageWorkspace implements DesignWorkspaceModule {
});
}
updateGenerationQuote(
input: DesignGenerationQuoteUpdateInput,
): Promise<DesignGenerationQuote> {
return this.enqueue(async () => {
const state = await this.load();
const workspace = this.requireWorkspace(state, input.workspaceId);
const conversation = (state.conversationsByWorkspaceId[input.workspaceId] ?? [])
.find((candidate) => candidate.messages.some((message) => (
message.generationQuote?.quoteId === input.quoteId
)));
const quote = conversation?.messages
.map((message) => message.generationQuote)
.find((candidate) => candidate?.quoteId === input.quoteId);
if (!conversation || !quote || quote.status !== 'active') {
throw new LocalImageWorkspaceError(409, 'generation_quote_invalid', '当前生成报价已失效');
}
if (new Date(quote.expiresAt).getTime() <= this.now().getTime()) {
quote.status = 'expired';
throw new LocalImageWorkspaceError(409, 'generation_quote_expired', '当前生成报价已过期');
}
if (!input.finalPrompt.trim()) {
throw new LocalImageWorkspaceError(422, 'generation_prompt_invalid', '提示词不能为空');
}
const amount = localGenerationPricing(quote.medium, input.generationParameters);
quote.finalPrompt = input.finalPrompt;
quote.generationParameters = clone(input.generationParameters);
quote.pricing = {
...quote.pricing,
amount,
};
quote.quotedDesignPoints = amount;
const timestamp = this.now().toISOString();
conversation.updatedAt = timestamp;
workspace.viewRevision += 1;
workspace.updatedAt = timestamp;
await this.persist(state);
return clone(quote);
});
}
confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignConversation> {
return this.mutateConversation((state) => {
const workspace = this.requireWorkspace(state, input.workspaceId);
@@ -410,6 +691,14 @@ export class LocalImageWorkspace implements DesignWorkspaceModule {
quote.status = 'expired';
throw new LocalImageWorkspaceError(409, 'generation_quote_expired', '当前生成报价已过期');
}
if (quote.finalPrompt !== input.finalPrompt
|| !sameGenerationParameters(quote.generationParameters, input.generationParameters)) {
throw new LocalImageWorkspaceError(
409,
'generation_quote_changed',
'生成参数已变更,请等待重新报价完成',
);
}
quote.status = 'consumed';
const timestamp = this.now().toISOString();
@@ -609,7 +898,7 @@ export class LocalImageWorkspace implements DesignWorkspaceModule {
'AI 设计本地数据暂时无法读取',
);
}
this.state = parsed;
this.state = normalizePersistedGenerationQuotes(parsed);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
this.state = await this.loadLegacyState();
@@ -631,6 +920,7 @@ export class LocalImageWorkspace implements DesignWorkspaceModule {
);
}
const migrated = this.migrateLegacyState(parsed);
normalizePersistedGenerationQuotes(migrated);
await this.persist(migrated);
return migrated;
} catch (error) {

View File

@@ -6,6 +6,9 @@ import type {
DesignCreateConversationInput,
DesignConfirmGenerationInput,
DesignCreateWorkspaceInput,
DesignDeleteWorkspaceResult,
DesignGenerationQuote,
DesignGenerationQuoteUpdateInput,
DesignGenerationTask,
DesignRenameWorkspaceInput,
DesignSubmitMessageInput,
@@ -46,11 +49,13 @@ export interface DesignWorkspaceModule {
bootstrap(): Promise<DesignWorkspaceBootstrap>;
getCapabilities(): Promise<DesignCapabilities>;
createWorkspace(input: DesignCreateWorkspaceInput): Promise<DesignWorkspace>;
deleteWorkspace(workspaceId: string): Promise<DesignDeleteWorkspaceResult>;
renameWorkspace(input: DesignRenameWorkspaceInput): Promise<DesignWorkspace>;
getWorkspace(workspaceId: string): Promise<DesignWorkspace>;
createConversation(input: DesignCreateConversationInput): Promise<DesignConversation>;
getConversation(workspaceId: string, conversationId: string): Promise<DesignConversation>;
submitMessage(input: DesignSubmitMessageInput): Promise<DesignConversation>;
updateGenerationQuote(input: DesignGenerationQuoteUpdateInput): Promise<DesignGenerationQuote>;
confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignConversation>;
listTasks(workspaceId: string): Promise<DesignGenerationTask[]>;
uploadAsset?(input: DesignAssetUploadInput): Promise<DesignAsset>;

View File

@@ -10,6 +10,9 @@ import type {
DesignConfirmGenerationInput,
DesignCreateConversationInput,
DesignCreateWorkspaceInput,
DesignDeleteWorkspaceResult,
DesignGenerationOption,
DesignGenerationQuoteUpdateInput,
DesignGenerationQuote,
DesignGenerationTask,
DesignGenerationTaskUpdatedEvent,
@@ -48,6 +51,25 @@ type ServerQuote = {
medium: DesignGenerationQuote['medium'];
brief_version: number;
brief_summary: string;
final_prompt: string;
prompt_mode: string;
generation_parameters: {
model: string;
resolution: string;
aspect_ratio: string;
duration_seconds: number | null;
};
generation_options: {
models: Array<DesignGenerationOption<string>>;
resolutions: Array<DesignGenerationOption<string>>;
durations: Array<DesignGenerationOption<number>>;
aspect_ratios: Array<DesignGenerationOption<string>>;
};
pricing: {
schema: string;
amount: number;
rounding: string;
};
quoted_design_points: number;
expires_at: string;
};
@@ -176,6 +198,13 @@ type AgentDesignTurnSubmission = {
action: null | {
type: 'confirm_generation';
quote_id: string;
final_prompt?: string;
aspect_ratio?: string;
generation_parameters?: {
model: string;
resolution: string;
duration_seconds: number | null;
};
};
};
@@ -260,6 +289,25 @@ function mapQuote(quote: ServerQuote | null): DesignGenerationQuote | null {
medium: quote.medium,
briefVersion: quote.brief_version,
briefSummary: quote.brief_summary,
finalPrompt: quote.final_prompt,
promptMode: quote.prompt_mode,
generationParameters: {
model: quote.generation_parameters.model,
resolution: quote.generation_parameters.resolution,
aspectRatio: quote.generation_parameters.aspect_ratio,
durationSeconds: quote.generation_parameters.duration_seconds,
},
generationOptions: {
models: quote.generation_options.models,
resolutions: quote.generation_options.resolutions,
durations: quote.generation_options.durations,
aspectRatios: quote.generation_options.aspect_ratios,
},
pricing: {
schema: quote.pricing.schema,
amount: quote.pricing.amount,
rounding: quote.pricing.rounding,
},
quotedDesignPoints: quote.quoted_design_points,
expiresAt: quote.expires_at,
};
@@ -810,6 +858,15 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
return this.getWorkspace(workspace.workspace_id);
}
async deleteWorkspace(workspaceId: string): Promise<DesignDeleteWorkspaceResult> {
await this.requestJson<unknown>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}`,
{ method: 'DELETE' },
);
this.forgetWorkspace(workspaceId);
return { workspaceId, deleted: true };
}
async renameWorkspace(input: DesignRenameWorkspaceInput): Promise<DesignWorkspace> {
const workspace = await this.requestJson<ServerWorkspace>(
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}`,
@@ -873,8 +930,43 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
});
}
async updateGenerationQuote(
input: DesignGenerationQuoteUpdateInput,
): Promise<DesignGenerationQuote> {
const quote = await this.requestJson<ServerQuote>(
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/generation-quotes/${encodeURIComponent(input.quoteId)}`,
{
method: 'PATCH',
body: JSON.stringify({
final_prompt: input.finalPrompt,
model: input.generationParameters.model,
resolution: input.generationParameters.resolution,
aspect_ratio: input.generationParameters.aspectRatio,
duration_seconds: input.generationParameters.durationSeconds,
}),
},
);
return mapQuote(quote)!;
}
async confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignConversation> {
const agentSessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId);
const action = input.finalPrompt === undefined || input.generationParameters === undefined
? {
type: 'confirm_generation' as const,
quote_id: input.quoteId,
}
: {
type: 'confirm_generation' as const,
quote_id: input.quoteId,
final_prompt: input.finalPrompt,
aspect_ratio: input.generationParameters.aspectRatio,
generation_parameters: {
model: input.generationParameters.model,
resolution: input.generationParameters.resolution,
duration_seconds: input.generationParameters.durationSeconds,
},
};
return this.executeAgentTurn({
workspaceId: input.workspaceId,
conversationId: input.conversationId,
@@ -883,10 +975,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
expectedTurnRevision: input.expectedTurnRevision,
message: '确认生成',
attachmentAssetIds: [],
action: {
type: 'confirm_generation',
quote_id: input.quoteId,
},
action,
});
}
@@ -1438,6 +1527,30 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
this.conversationSessionIds.delete(this.conversationKey(workspaceId, conversationId));
}
private forgetWorkspace(workspaceId: string): void {
const keyPrefix = `${workspaceId}:`;
const sessionIds = new Set<string>();
for (const [key, sessionId] of this.conversationSessionIds) {
if (!key.startsWith(keyPrefix)) continue;
sessionIds.add(sessionId);
this.conversationSessionIds.delete(key);
}
const activeSubscriptions: Array<() => void> = [];
for (const [key, closers] of this.eventSubscriptionClosers) {
if (!key.startsWith(keyPrefix)) continue;
activeSubscriptions.push(...closers);
this.eventSubscriptionClosers.delete(key);
}
for (const close of activeSubscriptions) close();
for (const key of this.terminalAgentRuns.keys()) {
if ([...sessionIds].some((sessionId) => key.startsWith(`${sessionId}:`))) {
this.terminalAgentRuns.delete(key);
}
}
}
private async getAgentSessionId(
workspaceId: string,
conversationId: string,