feat: 完善图像工作区与创作工具体验
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user