feat: 完善图像工作区与创作工具体验
This commit is contained in:
158
electron/api/routes/image-prompt-museum.ts
Normal file
158
electron/api/routes/image-prompt-museum.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { sendJson } from '../route-utils';
|
||||
import { WORKS_SQUARE_CONFIG } from '../works-config';
|
||||
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
|
||||
const LOCAL_ROOT = '/api/works/image-prompt-museum';
|
||||
const UPSTREAM_ROOT = '/api/image-prompt-museum';
|
||||
const LIST_QUERY_KEYS = ['q', 'use_case', 'style', 'subject', 'language', 'model', 'cursor', 'limit'] as const;
|
||||
const MAX_QUERY_VALUE_LENGTH = 256;
|
||||
|
||||
type PromptMuseumRouteDependencies = {
|
||||
fetchImpl?: typeof fetch;
|
||||
getAccessToken?: typeof getValidWorksSquareAccessToken;
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
|
||||
function isMuseumPath(pathname: string): boolean {
|
||||
return pathname === LOCAL_ROOT || /^\/api\/works\/image-prompt-museum\/[^/]+$/.test(pathname);
|
||||
}
|
||||
|
||||
function upstreamPath(pathname: string): string | null {
|
||||
if (pathname === LOCAL_ROOT) return UPSTREAM_ROOT;
|
||||
const entryId = pathname.slice(`${LOCAL_ROOT}/`.length);
|
||||
if (!entryId) return null;
|
||||
return `${UPSTREAM_ROOT}/${encodeURIComponent(decodeURIComponent(entryId))}`;
|
||||
}
|
||||
|
||||
function copyAllowedQuery(source: URL): string {
|
||||
const query = new URLSearchParams();
|
||||
for (const key of LIST_QUERY_KEYS) {
|
||||
const value = source.searchParams.get(key)?.trim();
|
||||
if (!value || value.length > MAX_QUERY_VALUE_LENGTH) continue;
|
||||
query.set(key, value);
|
||||
}
|
||||
const encoded = query.toString();
|
||||
return encoded ? `?${encoded}` : '';
|
||||
}
|
||||
|
||||
async function readPayload(response: Response): Promise<unknown> {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(payload: unknown, fallback: string): string {
|
||||
if (typeof payload === 'object' && payload !== null && !Array.isArray(payload)) {
|
||||
const value = (payload as Record<string, unknown>).error;
|
||||
if (typeof value === 'string' && value.trim()) return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function createImagePromptMuseumRouteHandler(
|
||||
dependencies: PromptMuseumRouteDependencies = {},
|
||||
) {
|
||||
const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch;
|
||||
const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken;
|
||||
const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
||||
|
||||
return async function handleImagePromptMuseumRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (!isMuseumPath(url.pathname)) return false;
|
||||
if (req.method !== 'GET') {
|
||||
sendJson(res, 405, {
|
||||
success: false,
|
||||
code: 'PROMPT_MUSEUM_METHOD_NOT_ALLOWED',
|
||||
error: '提示词博物馆只支持读取',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const path = upstreamPath(url.pathname);
|
||||
if (!path) {
|
||||
sendJson(res, 404, {
|
||||
success: false,
|
||||
code: 'PROMPT_MUSEUM_NOT_FOUND',
|
||||
error: '提示词条目不存在',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getAccessToken({ fetchImpl });
|
||||
if (!token) {
|
||||
sendJson(res, 401, {
|
||||
success: false,
|
||||
status: 401,
|
||||
code: 'PROMPT_MUSEUM_AUTH_REQUIRED',
|
||||
error: '请先登录后再获取灵感',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = (accessToken: string) => fetchImpl(
|
||||
`${apiBaseUrl}${path}${url.pathname === LOCAL_ROOT ? copyAllowedQuery(url) : ''}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
redirect: 'manual',
|
||||
},
|
||||
);
|
||||
|
||||
let response = await request(token);
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true });
|
||||
if (refreshed) response = await request(refreshed);
|
||||
}
|
||||
|
||||
const payload = await readPayload(response);
|
||||
if (!response.ok) {
|
||||
sendJson(res, response.status, {
|
||||
success: false,
|
||||
status: response.status,
|
||||
code: typeof payload === 'object' && payload !== null && !Array.isArray(payload)
|
||||
&& typeof (payload as Record<string, unknown>).code === 'string'
|
||||
? (payload as Record<string, unknown>).code
|
||||
: response.status === 404 ? 'PROMPT_MUSEUM_NOT_FOUND' : 'PROMPT_MUSEUM_UNAVAILABLE',
|
||||
error: errorMessage(payload, '提示词博物馆暂时不可用'),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (payload === null) {
|
||||
sendJson(res, 502, {
|
||||
success: false,
|
||||
status: 502,
|
||||
code: 'PROMPT_MUSEUM_INVALID_RESPONSE',
|
||||
error: '提示词博物馆返回了无效数据',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
sendJson(res, response.status, payload);
|
||||
return true;
|
||||
} catch (error) {
|
||||
sendJson(res, 502, {
|
||||
success: false,
|
||||
status: 502,
|
||||
code: 'PROMPT_MUSEUM_UNAVAILABLE',
|
||||
error: error instanceof Error ? error.message : '提示词博物馆暂时不可用',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const handleImagePromptMuseumRoutes = createImagePromptMuseumRouteHandler();
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type DesignCreateConversationInput,
|
||||
type DesignConfirmGenerationInput,
|
||||
type DesignCreateWorkspaceInput,
|
||||
type DesignGenerationParameters,
|
||||
type DesignRenameWorkspaceInput,
|
||||
type DesignSubmitMessageInput,
|
||||
} from '../../../shared/image-workspace';
|
||||
@@ -50,6 +51,25 @@ function asStringArray(value: unknown): string[] {
|
||||
: [];
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function generationParametersFromBody(body: Record<string, unknown>): DesignGenerationParameters {
|
||||
return {
|
||||
model: asString(body.model),
|
||||
resolution: asString(body.resolution),
|
||||
aspectRatio: asString(body.aspectRatio),
|
||||
durationSeconds: body.durationSeconds === null
|
||||
? null
|
||||
: typeof body.durationSeconds === 'number' && Number.isFinite(body.durationSeconds)
|
||||
? body.durationSeconds
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const DESIGN_IMAGE_UPLOAD_MIME_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
@@ -390,6 +410,11 @@ export async function handleImageWorkspaceRoutes(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segments.length === 2 && segments[0] === 'workspaces' && req.method === 'DELETE') {
|
||||
sendData(res, await ctx.imageWorkspace.deleteWorkspace(segments[1]));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segments.length === 2 && segments[0] === 'workspaces' && req.method === 'PATCH') {
|
||||
const body = await parseJsonBody<Record<string, unknown>>(req);
|
||||
const input: DesignRenameWorkspaceInput = {
|
||||
@@ -448,6 +473,20 @@ export async function handleImageWorkspaceRoutes(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segments.length === 4
|
||||
&& segments[0] === 'workspaces'
|
||||
&& segments[2] === 'generation-quotes'
|
||||
&& req.method === 'PATCH') {
|
||||
const body = await parseJsonBody<Record<string, unknown>>(req);
|
||||
sendData(res, await ctx.imageWorkspace.updateGenerationQuote({
|
||||
workspaceId: segments[1],
|
||||
quoteId: segments[3],
|
||||
finalPrompt: typeof body.finalPrompt === 'string' ? body.finalPrompt : '',
|
||||
generationParameters: generationParametersFromBody(body),
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segments.length === 3
|
||||
&& segments[0] === 'workspaces'
|
||||
&& segments[2] === 'assets'
|
||||
@@ -489,6 +528,8 @@ export async function handleImageWorkspaceRoutes(
|
||||
quoteId: segments[5],
|
||||
clientTurnId: asString(body.clientTurnId),
|
||||
expectedTurnRevision: asInteger(body.expectedTurnRevision),
|
||||
finalPrompt: typeof body.finalPrompt === 'string' ? body.finalPrompt : '',
|
||||
generationParameters: generationParametersFromBody(asRecord(body.generationParameters)),
|
||||
};
|
||||
sendData(res, await ctx.imageWorkspace.confirmGeneration(input));
|
||||
return true;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { handleAppRoutes } from './routes/app';
|
||||
import { handleAuthRoutes } from './routes/auth';
|
||||
import { handleOpencodeRoutes } from './routes/opencode';
|
||||
import { handleImageWorkspaceRoutes } from './routes/image-workspace';
|
||||
import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum';
|
||||
import { handleWorksRoutes } from './routes/works';
|
||||
import { handleUserSyncRoutes } from './routes/user-sync';
|
||||
import { handleSettingsRoutes } from './routes/settings';
|
||||
@@ -34,6 +35,7 @@ const coreRouteHandlers: RouteHandler[] = [
|
||||
handleAppRoutes,
|
||||
handleAuthRoutes,
|
||||
handleImageWorkspaceRoutes,
|
||||
handleImagePromptMuseumRoutes,
|
||||
handleWorksRoutes,
|
||||
handleAgentBrowserRoutes,
|
||||
handleUserSyncRoutes,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -118,7 +118,15 @@ async function getSettingsStore() {
|
||||
*/
|
||||
export async function getSetting<K extends keyof AppSettings>(key: K): Promise<AppSettings[K]> {
|
||||
const store = await getSettingsStore();
|
||||
return store.get(key);
|
||||
const value = store.get(key);
|
||||
if (key === 'language') {
|
||||
const resolvedLanguage = resolveSupportedLanguage(value as string);
|
||||
if (value !== resolvedLanguage) {
|
||||
store.set(key, resolvedLanguage);
|
||||
}
|
||||
return resolvedLanguage as AppSettings[K];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,6 +137,10 @@ export async function setSetting<K extends keyof AppSettings>(
|
||||
value: AppSettings[K]
|
||||
): Promise<void> {
|
||||
const store = await getSettingsStore();
|
||||
if (key === 'language') {
|
||||
store.set(key, resolveSupportedLanguage(value as string));
|
||||
return;
|
||||
}
|
||||
store.set(key, value);
|
||||
}
|
||||
|
||||
@@ -170,7 +182,12 @@ export function rotateAgentGatewaySessionClientId(workspaceId: string): Promise<
|
||||
*/
|
||||
export async function getAllSettings(): Promise<AppSettings> {
|
||||
const store = await getSettingsStore();
|
||||
return store.store;
|
||||
const settings = store.store;
|
||||
const language = resolveSupportedLanguage(settings.language);
|
||||
if (settings.language !== language) {
|
||||
store.set('language', language);
|
||||
}
|
||||
return { ...settings, language };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user