feat: 统一 AI 设计 Workspace 与生成任务链路

需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。

实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
This commit is contained in:
2026-07-31 13:56:55 +08:00
parent 80e8386fa6
commit 3d9dd14918
22 changed files with 2506 additions and 1707 deletions

View File

@@ -1,66 +1,50 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { dirname, join, resolve } from 'node:path';
import type {
ImageWorkspaceCapabilities,
ImageWorkspaceGenerationSettings,
ImageWorkspaceImage,
ImageWorkspaceProject,
ImageWorkspaceReferenceUploadInput,
ImageWorkspaceReferenceUploadResult,
ImageWorkspaceSendMessageInput,
ImageWorkspaceSnapshot,
DesignCapabilities,
DesignConfirmGenerationInput,
DesignCreateWorkspaceInput,
DesignGenerationQuote,
DesignGenerationTask,
DesignMedium,
DesignRenameWorkspaceInput,
DesignSubmitMessageInput,
DesignWorkspace,
DesignWorkspaceBootstrap,
} from '../../shared/image-workspace';
import { designAssetContentPath } from '../../shared/image-workspace';
import { DesignWorkspaceModuleError, type DesignWorkspaceModule } from './module';
const LOCAL_WORKSPACE_SCHEMA_VERSION = 1;
const LOCAL_WORKSPACE_SCHEMA_VERSION = 2;
const LOCAL_WORKSPACE_DIRECTORY = 'image-workspace-development';
const LOCAL_WORKSPACE_FILE = 'workspace.json';
const LOCAL_WORKSPACE_FILE = 'design-workspace-v2.json';
const MAX_PROJECT_NAME_LENGTH = 80;
const MAX_PROMPT_LENGTH = 4_000;
const MAX_AGENT_COUNT = 50;
const MAX_REFERENCE_BYTES = 5 * 1024 * 1024;
const MAX_MESSAGE_LENGTH = 4_000;
const LOCAL_CAPABILITIES: ImageWorkspaceCapabilities = {
modes: [
{ id: 'generate', label: '图片生成', description: '从文字或参考图生成新的图片' },
{ id: 'edit', label: '参考图编辑', description: '使用参考图继续调整画面' },
],
models: [
{ id: 'local-placeholder-v1', label: '标准创作模型', description: '适用于图片生成与参考图编辑' },
],
aspectRatios: [
{ id: '1:1', label: '1:1' },
{ id: '3:4', label: '3:4' },
{ id: '4:3', label: '4:3' },
{ id: '16:9', label: '16:9' },
],
resolutions: [
{ id: '1024', label: '1K' },
{ id: '2048', label: '2K' },
],
outputCounts: [
{ id: '1', label: '1 张' },
{ id: '2', label: '2 张' },
{ id: '4', label: '4 张' },
],
defaultModeId: 'generate',
defaultModelId: 'local-placeholder-v1',
defaultAspectRatioId: '1:1',
defaultResolutionId: '1024',
defaultOutputCountId: '1',
maxReferenceImages: 4,
referenceUpload: {
enabled: true,
acceptedMimeTypes: ['image/png', 'image/jpeg', 'image/webp'],
maxBytes: MAX_REFERENCE_BYTES,
},
const LOCAL_CAPABILITIES: DesignCapabilities = {
conversation: true,
generation: true,
image: true,
video: true,
};
type PersistedWorkspace = DesignWorkspace & {
clientWorkspaceId: string;
requestHashes: Record<string, string>;
};
type PersistedAsset = {
workspaceId: string;
mimeType: string;
contentBase64: string;
};
type PersistedImageWorkspace = {
schemaVersion: typeof LOCAL_WORKSPACE_SCHEMA_VERSION;
activeProjectId: string | null;
projects: ImageWorkspaceProject[];
referencesByProjectId: Record<string, ImageWorkspaceImage[]>;
workspaces: PersistedWorkspace[];
tasksByWorkspaceId: Record<string, DesignGenerationTask[]>;
assetsById: Record<string, PersistedAsset>;
};
type LocalImageWorkspaceOptions = {
@@ -69,24 +53,19 @@ type LocalImageWorkspaceOptions = {
createId?: () => string;
};
export class LocalImageWorkspaceError extends Error {
readonly status: number;
readonly code: string;
export class LocalImageWorkspaceError extends DesignWorkspaceModuleError {
constructor(status: number, code: string, message: string) {
super(message);
super(status, code, message);
this.name = 'LocalImageWorkspaceError';
this.status = status;
this.code = code;
}
}
function createEmptyState(): PersistedImageWorkspace {
return {
schemaVersion: LOCAL_WORKSPACE_SCHEMA_VERSION,
activeProjectId: null,
projects: [],
referencesByProjectId: {},
workspaces: [],
tasksByWorkspaceId: {},
assetsById: {},
};
}
@@ -101,9 +80,9 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function isPersistedWorkspace(value: unknown): value is PersistedImageWorkspace {
return isRecord(value)
&& value.schemaVersion === LOCAL_WORKSPACE_SCHEMA_VERSION
&& (typeof value.activeProjectId === 'string' || value.activeProjectId === null)
&& Array.isArray(value.projects)
&& isRecord(value.referencesByProjectId);
&& Array.isArray(value.workspaces)
&& isRecord(value.tasksByWorkspaceId)
&& isRecord(value.assetsById);
}
function escapeXml(value: string): string {
@@ -115,138 +94,71 @@ function escapeXml(value: string): string {
.replaceAll("'", '&apos;');
}
function wrapPrompt(prompt: string, maxCharacters = 24, maxLines = 3): string[] {
const characters = Array.from(prompt.replace(/\s+/g, ' ').trim());
const lines: string[] = [];
for (let index = 0; index < characters.length && lines.length < maxLines; index += maxCharacters) {
const line = characters.slice(index, index + maxCharacters).join('');
const truncated = index + maxCharacters < characters.length && lines.length === maxLines - 1;
lines.push(truncated ? `${line.slice(0, Math.max(0, line.length - 1))}` : line);
}
return lines.length > 0 ? lines : ['未填写提示词'];
function messageHash(value: unknown): string {
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
}
function resolveDimensions(settings: Required<ImageWorkspaceGenerationSettings>): { width: number; height: number } {
const longEdge = settings.resolutionId === '2048' ? 2048 : 1024;
const [widthRatio, heightRatio] = (settings.aspectRatioId ?? '1:1')
.split(':')
.map((value) => Number(value));
if (!widthRatio || !heightRatio) return { width: longEdge, height: longEdge };
if (widthRatio >= heightRatio) {
return { width: longEdge, height: Math.round(longEdge * heightRatio / widthRatio) };
}
return { width: Math.round(longEdge * widthRatio / heightRatio), height: longEdge };
function detectMedium(message: string): DesignMedium {
return /视频|动画|动效|镜头|转场/.test(message) ? 'video' : 'image';
}
function optionId(
value: string | undefined,
defaultValue: string,
options: Array<{ id: string }>,
label: string,
): string {
const resolved = value || defaultValue;
if (!options.some((option) => option.id === resolved)) {
throw new LocalImageWorkspaceError(400, 'IMAGE_WORKSPACE_INVALID_SETTING', `当前创作空间不支持这个${label}`);
}
return resolved;
function placeholderSvg(title: string, summary: string): string {
return [
'<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">',
'<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">',
'<stop stop-color="#eef2ff"/><stop offset="1" stop-color="#ffe4e6"/>',
'</linearGradient></defs>',
'<rect width="1280" height="720" rx="36" fill="url(#g)"/>',
'<circle cx="1080" cy="130" r="170" fill="#ffffff" opacity=".58"/>',
'<circle cx="170" cy="640" r="230" fill="#ffffff" opacity=".42"/>',
`<text x="86" y="285" font-family="Arial,sans-serif" font-size="32" fill="#6b7280">${escapeXml(title)}</text>`,
`<text x="86" y="370" font-family="Arial,sans-serif" font-size="54" font-weight="700" fill="#111827">${escapeXml(summary.slice(0, 28))}</text>`,
'<text x="86" y="450" font-family="Arial,sans-serif" font-size="24" fill="#6b7280">本地开发预览 · AI Design Workspace</text>',
'</svg>',
].join('');
}
function normalizeSettings(settings: ImageWorkspaceGenerationSettings): Required<ImageWorkspaceGenerationSettings> {
return {
modeId: optionId(settings.modeId, LOCAL_CAPABILITIES.defaultModeId!, LOCAL_CAPABILITIES.modes, '创作模式'),
modelId: optionId(settings.modelId, LOCAL_CAPABILITIES.defaultModelId!, LOCAL_CAPABILITIES.models, '模型'),
aspectRatioId: optionId(
settings.aspectRatioId,
LOCAL_CAPABILITIES.defaultAspectRatioId!,
LOCAL_CAPABILITIES.aspectRatios,
'画幅',
),
resolutionId: optionId(
settings.resolutionId,
LOCAL_CAPABILITIES.defaultResolutionId!,
LOCAL_CAPABILITIES.resolutions,
'分辨率',
),
outputCountId: optionId(
settings.outputCountId,
LOCAL_CAPABILITIES.defaultOutputCountId!,
LOCAL_CAPABILITIES.outputCounts,
'生成数量',
),
};
function validateTitle(value: string): string {
const title = value.trim();
if (!title || title.length > MAX_PROJECT_NAME_LENGTH) {
throw new LocalImageWorkspaceError(
400,
'IMAGE_WORKSPACE_INVALID_PROJECT_NAME',
'项目名称需为 180 个字符',
);
}
return title;
}
function createPlaceholderImage(
id: string,
prompt: string,
settings: Required<ImageWorkspaceGenerationSettings>,
referenceImageIds: string[],
outputIndex: number,
): ImageWorkspaceImage {
const seed = createHash('sha256')
.update(JSON.stringify({ prompt, settings, referenceImageIds, outputIndex }))
.digest('hex');
const hue = Number.parseInt(seed.slice(0, 4), 16) % 360;
const accentHue = (hue + 58 + Number.parseInt(seed.slice(4, 6), 16) % 90) % 360;
const { width, height } = resolveDimensions(settings);
const promptLines = wrapPrompt(prompt);
const promptText = promptLines.map((line, index) => (
`<tspan x="${Math.round(width * 0.08)}" dy="${index === 0 ? 0 : Math.round(height * 0.075)}">${escapeXml(line)}</tspan>`
)).join('');
const metadata = [
LOCAL_CAPABILITIES.models.find((model) => model.id === settings.modelId)?.label ?? '标准创作模型',
settings.aspectRatioId,
`${width}×${height}`,
referenceImageIds.length > 0 ? `${referenceImageIds.length} 张参考图` : '纯文字创作',
].join(' · ');
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="${width}" height="${height}" fill="hsl(${hue} 72% 88%)"/>
<circle cx="${Math.round(width * 0.82)}" cy="${Math.round(height * 0.2)}" r="${Math.round(Math.min(width, height) * 0.28)}" fill="hsl(${accentHue} 78% 68%)" opacity="0.72"/>
<rect x="${Math.round(width * 0.06)}" y="${Math.round(height * 0.08)}" width="${Math.round(width * 0.88)}" height="${Math.round(height * 0.84)}" rx="${Math.round(Math.min(width, height) * 0.04)}" fill="white" fill-opacity="0.78" stroke="#202638" stroke-width="${Math.max(4, Math.round(Math.min(width, height) * 0.008))}"/>
<text x="${Math.round(width * 0.08)}" y="${Math.round(height * 0.19)}" fill="#202638" font-family="Arial, sans-serif" font-size="${Math.max(24, Math.round(Math.min(width, height) * 0.055))}" font-weight="800">CREATIVE · ${outputIndex + 1}</text>
<text x="${Math.round(width * 0.08)}" y="${Math.round(height * 0.39)}" fill="#202638" font-family="Arial, sans-serif" font-size="${Math.max(22, Math.round(Math.min(width, height) * 0.047))}" font-weight="700">${promptText}</text>
<text x="${Math.round(width * 0.08)}" y="${Math.round(height * 0.82)}" fill="#5f4638" font-family="Arial, sans-serif" font-size="${Math.max(16, Math.round(Math.min(width, height) * 0.027))}" font-weight="700">${escapeXml(metadata)}</text>
</svg>
`.trim();
const url = `data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`;
return {
id,
url,
thumbnailUrl: url,
alt: `生成图片:${prompt}`,
};
}
function validateBase64(contentBase64: string): Buffer {
const normalized = contentBase64.trim();
if (!normalized || normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
throw new LocalImageWorkspaceError(400, 'IMAGE_WORKSPACE_INVALID_REFERENCE', '参考图内容不是有效的 Base64');
function validateMessage(value: string): string {
const message = value.trim();
if (!message || message.length > MAX_MESSAGE_LENGTH) {
throw new LocalImageWorkspaceError(
400,
'IMAGE_WORKSPACE_INVALID_MESSAGE',
'设计描述需为 14000 个字符',
);
}
const bytes = Buffer.from(normalized, 'base64');
if (bytes.length === 0 || bytes.length > MAX_REFERENCE_BYTES) {
throw new LocalImageWorkspaceError(400, 'IMAGE_WORKSPACE_INVALID_REFERENCE', '参考图大小必须在 5 MB 以内');
}
return bytes;
return message;
}
export function isLocalImageWorkspaceDevelopmentEnabled(options: {
isPackaged: boolean;
configuredMode?: string;
configuredMode?: string | null;
isDevelopmentServer?: boolean;
}): boolean {
if (options.isPackaged) return false;
const configuredMode = options.configuredMode?.trim().toLowerCase();
return configuredMode === 'local'
|| (!configuredMode && options.isDevelopmentServer === true);
if (configuredMode === 'cloud') return false;
if (configuredMode === 'local') return true;
return options.isDevelopmentServer === true;
}
export function getLocalImageWorkspaceDirectory(userDataDir: string): string {
return join(userDataDir, LOCAL_WORKSPACE_DIRECTORY);
}
export class LocalImageWorkspace {
export class LocalImageWorkspace implements DesignWorkspaceModule {
private readonly rootDirectory: string;
private readonly stateFile: string;
private readonly now: () => Date;
@@ -255,149 +167,290 @@ export class LocalImageWorkspace {
private operation: Promise<void> = Promise.resolve();
constructor(options: LocalImageWorkspaceOptions) {
this.rootDirectory = getLocalImageWorkspaceDirectory(options.userDataDir);
const userDataDirectory = resolve(options.userDataDir);
this.rootDirectory = resolve(getLocalImageWorkspaceDirectory(userDataDirectory));
if (dirname(this.rootDirectory) !== userDataDirectory) {
throw new Error('Invalid local AI design workspace directory');
}
this.stateFile = join(this.rootDirectory, LOCAL_WORKSPACE_FILE);
this.now = options.now ?? (() => new Date());
this.createId = options.createId ?? randomUUID;
}
getSnapshot(): Promise<ImageWorkspaceSnapshot> {
return this.enqueue(async () => this.snapshot(await this.load()));
async bootstrap(): Promise<DesignWorkspaceBootstrap> {
const state = await this.load();
return this.bootstrapView(state);
}
createProject(name: string): Promise<ImageWorkspaceSnapshot> {
return this.mutate(async (state) => {
const projectName = name.trim();
if (!projectName || projectName.length > MAX_PROJECT_NAME_LENGTH) {
throw new LocalImageWorkspaceError(400, 'IMAGE_WORKSPACE_INVALID_PROJECT_NAME', '项目名称需为 180 个字符');
}
getSnapshot(): Promise<DesignWorkspaceBootstrap> {
return this.bootstrap();
}
async getCapabilities(): Promise<DesignCapabilities> {
return clone(LOCAL_CAPABILITIES);
}
createWorkspace(input: DesignCreateWorkspaceInput): Promise<DesignWorkspace> {
return this.mutate((state) => {
const existing = state.workspaces.find(
(workspace) => workspace.clientWorkspaceId === input.clientWorkspaceId,
);
if (existing) return existing;
const timestamp = this.now().toISOString();
const projectId = `local-project-${this.createId()}`;
state.projects.push({
id: projectId,
name: projectName,
agents: [],
activeAgentId: null,
const workspace: PersistedWorkspace = {
workspaceId: `local-workspace-${this.createId()}`,
clientWorkspaceId: input.clientWorkspaceId,
title: validateTitle(input.title),
turnRevision: 0,
viewRevision: 0,
phase: 'shaping',
brief: {
version: 0,
status: 'draft',
medium: null,
summary: '正在建立作品的视觉方向',
ready: false,
missingDecision: '作品形式',
},
messages: [],
requestHashes: {},
updatedAt: timestamp,
});
state.referencesByProjectId[projectId] = [];
state.activeProjectId = projectId;
};
state.workspaces.unshift(workspace);
state.tasksByWorkspaceId[workspace.workspaceId] = [];
return workspace;
});
}
addAgent(projectId: string): Promise<ImageWorkspaceSnapshot> {
return this.mutate(async (state) => {
const project = this.requireProject(state, projectId);
if (project.agents.length >= MAX_AGENT_COUNT) {
throw new LocalImageWorkspaceError(409, 'IMAGE_WORKSPACE_AGENT_LIMIT', '每个项目最多添加 50 个 Agent');
}
const agentId = `local-agent-${this.createId()}`;
project.agents.push({ id: agentId, name: `创作 Agent ${project.agents.length + 1}` });
project.updatedAt = this.now().toISOString();
renameWorkspace(input: DesignRenameWorkspaceInput): Promise<DesignWorkspace> {
return this.mutate((state) => {
const workspace = this.requireWorkspace(state, input.workspaceId);
workspace.title = validateTitle(input.title);
workspace.viewRevision += 1;
workspace.updatedAt = this.now().toISOString();
return workspace;
});
}
sendMessage(input: ImageWorkspaceSendMessageInput): Promise<ImageWorkspaceSnapshot> {
return this.mutate(async (state) => {
const project = this.requireProject(state, input.projectId);
if (!project.agents.some((agent) => agent.id === input.agentId)) {
throw new LocalImageWorkspaceError(404, 'IMAGE_WORKSPACE_AGENT_NOT_FOUND', '当前项目中不存在这个 Agent');
}
const prompt = input.prompt.trim();
if (!prompt || prompt.length > MAX_PROMPT_LENGTH) {
throw new LocalImageWorkspaceError(400, 'IMAGE_WORKSPACE_INVALID_PROMPT', '创作描述需为 14000 个字符');
}
const referenceIds = [...new Set(input.referenceImageIds)];
if (referenceIds.length > LOCAL_CAPABILITIES.maxReferenceImages) {
throw new LocalImageWorkspaceError(400, 'IMAGE_WORKSPACE_REFERENCE_LIMIT', '参考图数量超过创作空间限制');
}
const availableImages = new Map<string, ImageWorkspaceImage>();
for (const image of state.referencesByProjectId[project.id] ?? []) availableImages.set(image.id, image);
for (const message of project.messages) {
for (const image of message.images) availableImages.set(image.id, image);
}
const references = referenceIds.map((id) => {
const image = availableImages.get(id);
if (!image) {
throw new LocalImageWorkspaceError(400, 'IMAGE_WORKSPACE_REFERENCE_NOT_FOUND', '所选参考图不存在');
}
return image;
});
const settings = normalizeSettings(input.settings);
const outputCount = Number(settings.outputCountId);
async getWorkspace(workspaceId: string): Promise<DesignWorkspace> {
const state = await this.load();
return clone(this.requireWorkspace(state, workspaceId));
}
submitMessage(input: DesignSubmitMessageInput): Promise<DesignWorkspace> {
return this.mutate((state) => {
const workspace = this.requireWorkspace(state, input.workspaceId);
const message = validateMessage(input.message);
const hash = messageHash(input);
if (this.isRequestReplay(workspace, input.clientTurnId, hash)) return workspace;
this.assertRevision(workspace, input.expectedTurnRevision);
this.supersedeQuotes(workspace);
const timestamp = this.now().toISOString();
const images = Array.from({ length: outputCount }, (_, index) => createPlaceholderImage(
`local-image-${this.createId()}`,
prompt,
settings,
referenceIds,
index,
));
project.messages.push(
const nextRevision = workspace.turnRevision + 1;
const medium = detectMedium(message);
const quote: DesignGenerationQuote = {
quoteId: `local-quote-${this.createId()}`,
status: 'active',
medium,
briefVersion: nextRevision,
briefSummary: message,
quotedDesignPoints: medium === 'video' ? 2 : 1,
expiresAt: new Date(this.now().getTime() + 15 * 60 * 1000).toISOString(),
};
workspace.messages.push(
{
id: `local-message-${this.createId()}`,
role: 'user',
agentId: input.agentId,
text: prompt,
images: clone(references),
status: 'succeeded',
kind: 'user',
text: message,
quickReplies: [],
generationQuote: null,
turnRevision: nextRevision,
createdAt: timestamp,
},
{
id: `local-message-${this.createId()}`,
role: 'assistant',
agentId: input.agentId,
text: `已生成 ${outputCount} 张图片。`,
images,
status: 'succeeded',
kind: 'confirmation',
text: medium === 'video'
? '方向已整理为视频方案。确认后会创建视频生成任务,你也可以继续补充镜头、节奏或动效。'
: '方向已整理为图像方案。确认后会创建生图任务,你也可以继续调整构图、色彩或风格。',
quickReplies: ['确认生成', '继续调整'],
generationQuote: quote,
turnRevision: nextRevision,
createdAt: timestamp,
},
);
project.activeAgentId = input.agentId;
project.updatedAt = timestamp;
state.activeProjectId = project.id;
workspace.turnRevision = nextRevision;
workspace.viewRevision += 1;
workspace.phase = 'awaiting_confirmation';
workspace.brief = {
version: nextRevision,
status: 'ready',
medium,
summary: message,
ready: true,
missingDecision: null,
};
workspace.updatedAt = timestamp;
workspace.requestHashes[input.clientTurnId] = hash;
return workspace;
});
}
uploadReference(input: ImageWorkspaceReferenceUploadInput): Promise<ImageWorkspaceReferenceUploadResult> {
confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignWorkspace> {
return this.mutate((state) => {
const workspace = this.requireWorkspace(state, input.workspaceId);
const hash = messageHash(input);
if (this.isRequestReplay(workspace, input.clientTurnId, hash)) return workspace;
this.assertRevision(workspace, input.expectedTurnRevision);
const quote = workspace.messages
.map((message) => message.generationQuote)
.find((candidate) => candidate?.quoteId === input.quoteId);
if (!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', '当前生成报价已过期');
}
quote.status = 'consumed';
const timestamp = this.now().toISOString();
const nextRevision = workspace.turnRevision + 1;
workspace.messages.push(
{
id: `local-message-${this.createId()}`,
role: 'user',
kind: 'user',
text: '确认生成',
quickReplies: [],
generationQuote: null,
turnRevision: nextRevision,
createdAt: timestamp,
},
{
id: `local-message-${this.createId()}`,
role: 'assistant',
kind: 'confirmation',
text: `${quote.medium === 'video' ? '视频' : '图片'}任务已创建,可在右侧任务列表查看进度。`,
quickReplies: [],
generationQuote: null,
turnRevision: nextRevision,
createdAt: timestamp,
},
);
workspace.turnRevision = nextRevision;
workspace.viewRevision += 1;
workspace.phase = 'shaping';
workspace.brief = { ...workspace.brief, status: 'confirmed' };
workspace.updatedAt = timestamp;
workspace.requestHashes[input.clientTurnId] = hash;
state.tasksByWorkspaceId[workspace.workspaceId] ??= [];
state.tasksByWorkspaceId[workspace.workspaceId].unshift({
taskId: `local-task-${this.createId()}`,
workspaceId: workspace.workspaceId,
medium: quote.medium,
status: 'queued',
briefVersion: quote.briefVersion,
briefSummary: quote.briefSummary,
quoteId: quote.quoteId,
quotedDesignPoints: quote.quotedDesignPoints,
failureCode: null,
resultAssets: [],
createdAt: timestamp,
updatedAt: timestamp,
});
return workspace;
});
}
listTasks(workspaceId: string): Promise<DesignGenerationTask[]> {
return this.enqueue(async () => {
const state = await this.load();
const project = this.requireProject(state, input.projectId);
if (!LOCAL_CAPABILITIES.referenceUpload.acceptedMimeTypes.includes(input.mimeType)) {
throw new LocalImageWorkspaceError(400, 'IMAGE_WORKSPACE_INVALID_REFERENCE_TYPE', '创作空间仅支持 PNG、JPEG 和 WebP');
const workspace = this.requireWorkspace(state, workspaceId);
const tasks = state.tasksByWorkspaceId[workspaceId] ?? [];
const visible = clone(tasks);
let changed = false;
for (const task of tasks) {
if (task.status === 'queued') {
task.status = 'running';
task.updatedAt = this.now().toISOString();
workspace.viewRevision += 1;
workspace.updatedAt = task.updatedAt;
changed = true;
continue;
}
if (task.status !== 'running') continue;
const assetId = `local-asset-${this.createId()}`;
const svg = placeholderSvg(workspace.title, task.briefSummary);
const asset = {
assetId,
workspaceId,
mediaType: task.medium,
mimeType: 'image/svg+xml',
width: 1280,
height: 720,
durationMilliseconds: task.medium === 'video' ? 5_000 : null,
createdAt: this.now().toISOString(),
contentPath: designAssetContentPath(workspaceId, assetId),
};
state.assetsById[assetId] = {
workspaceId,
mimeType: asset.mimeType,
contentBase64: Buffer.from(svg).toString('base64'),
};
task.status = 'succeeded';
task.resultAssets = [asset];
task.updatedAt = asset.createdAt;
workspace.viewRevision += 1;
workspace.updatedAt = asset.createdAt;
changed = true;
}
const bytes = validateBase64(input.contentBase64);
const fileName = input.fileName.trim().slice(0, 180) || '本地参考图';
const reference: ImageWorkspaceImage = {
id: `local-reference-${this.createId()}`,
url: `data:${input.mimeType};base64,${bytes.toString('base64')}`,
alt: fileName,
};
state.referencesByProjectId[project.id] ??= [];
state.referencesByProjectId[project.id].push(reference);
project.updatedAt = this.now().toISOString();
await this.persist(state);
return { workspace: this.snapshot(state), reference: clone(reference) };
if (changed) await this.persist(state);
return visible;
});
}
reset(): Promise<ImageWorkspaceSnapshot> {
async openAssetContent(
workspaceId: string,
assetId: string,
_range?: string,
): Promise<Response> {
const state = await this.load();
this.requireWorkspace(state, workspaceId);
const asset = state.assetsById[assetId];
if (!asset || asset.workspaceId !== workspaceId) {
throw new LocalImageWorkspaceError(404, 'asset_not_found', '设计资产不存在');
}
const body = Buffer.from(asset.contentBase64, 'base64');
return new Response(body, {
status: 200,
headers: {
'Content-Type': asset.mimeType,
'Content-Length': String(body.byteLength),
'Cache-Control': 'private, max-age=60',
},
});
}
reset(): Promise<DesignWorkspaceBootstrap> {
return this.enqueue(async () => {
await rm(this.rootDirectory, { recursive: true, force: true });
this.state = createEmptyState();
return this.snapshot(this.state);
return this.bootstrapView(this.state);
});
}
private async mutate(
operation: (state: PersistedImageWorkspace) => Promise<void>,
): Promise<ImageWorkspaceSnapshot> {
private mutate(
operation: (state: PersistedImageWorkspace) => PersistedWorkspace,
): Promise<DesignWorkspace> {
return this.enqueue(async () => {
const state = await this.load();
await operation(state);
const workspace = operation(state);
await this.persist(state);
return this.snapshot(state);
return clone(workspace);
});
}
@@ -415,7 +468,7 @@ export class LocalImageWorkspace {
throw new LocalImageWorkspaceError(
500,
'IMAGE_WORKSPACE_LOCAL_DATA_INVALID',
'创作空间数据暂时无法读取',
'AI 设计本地数据暂时无法读取',
);
}
this.state = parsed;
@@ -444,19 +497,56 @@ export class LocalImageWorkspace {
this.state = state;
}
private requireProject(state: PersistedImageWorkspace, projectId: string): ImageWorkspaceProject {
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
throw new LocalImageWorkspaceError(404, 'IMAGE_WORKSPACE_PROJECT_NOT_FOUND', '项目不存在');
private requireWorkspace(
state: PersistedImageWorkspace,
workspaceId: string,
): PersistedWorkspace {
const workspace = state.workspaces.find((item) => item.workspaceId === workspaceId);
if (!workspace) {
throw new LocalImageWorkspaceError(404, 'workspace_not_found', '设计项目不存在');
}
return project;
return workspace;
}
private snapshot(state: PersistedImageWorkspace): ImageWorkspaceSnapshot {
private assertRevision(workspace: PersistedWorkspace, expectedRevision: number): void {
if (workspace.turnRevision !== expectedRevision) {
throw new LocalImageWorkspaceError(
409,
'workspace_revision_conflict',
'设计项目已在其他位置更新,请刷新后重试',
);
}
}
private isRequestReplay(
workspace: PersistedWorkspace,
clientTurnId: string,
hash: string,
): boolean {
const existing = workspace.requestHashes[clientTurnId];
if (!existing) return false;
if (existing !== hash) {
throw new LocalImageWorkspaceError(
409,
'idempotency_conflict',
'这次操作与已经提交的请求冲突',
);
}
return true;
}
private supersedeQuotes(workspace: PersistedWorkspace): void {
for (const message of workspace.messages) {
if (message.generationQuote?.status === 'active') {
message.generationQuote.status = 'superseded';
}
}
}
private bootstrapView(state: PersistedImageWorkspace): DesignWorkspaceBootstrap {
return {
capabilities: clone(LOCAL_CAPABILITIES),
projects: clone(state.projects),
activeProjectId: state.activeProjectId,
workspaces: state.workspaces.map(({ messages: _messages, clientWorkspaceId: _clientId, requestHashes: _requests, ...summary }) => clone(summary)),
};
}
}