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)),
};
}
}

View File

@@ -0,0 +1,35 @@
import type {
DesignCapabilities,
DesignConfirmGenerationInput,
DesignCreateWorkspaceInput,
DesignGenerationTask,
DesignRenameWorkspaceInput,
DesignSubmitMessageInput,
DesignWorkspace,
DesignWorkspaceBootstrap,
} from '../../shared/image-workspace';
export class DesignWorkspaceModuleError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = 'DesignWorkspaceModuleError';
this.status = status;
this.code = code;
}
}
export interface DesignWorkspaceModule {
bootstrap(): Promise<DesignWorkspaceBootstrap>;
getCapabilities(): Promise<DesignCapabilities>;
createWorkspace(input: DesignCreateWorkspaceInput): Promise<DesignWorkspace>;
renameWorkspace(input: DesignRenameWorkspaceInput): Promise<DesignWorkspace>;
getWorkspace(workspaceId: string): Promise<DesignWorkspace>;
submitMessage(input: DesignSubmitMessageInput): Promise<DesignWorkspace>;
confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignWorkspace>;
listTasks(workspaceId: string): Promise<DesignGenerationTask[]>;
openAssetContent(workspaceId: string, assetId: string, range?: string): Promise<Response>;
reset?(): Promise<DesignWorkspaceBootstrap>;
}

View File

@@ -0,0 +1,376 @@
import type {
DesignAsset,
DesignBrief,
DesignCapabilities,
DesignConfirmGenerationInput,
DesignCreateWorkspaceInput,
DesignGenerationQuote,
DesignGenerationTask,
DesignMessage,
DesignRenameWorkspaceInput,
DesignSubmitMessageInput,
DesignWorkspace,
DesignWorkspaceBootstrap,
DesignWorkspaceSummary,
} from '../../shared/image-workspace';
import { designAssetContentPath } from '../../shared/image-workspace';
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
import { proxyAwareFetch } from '../utils/proxy-fetch';
import { getValidWorksSquareAccessToken } from '../services/works-square-session';
import { DesignWorkspaceModuleError, type DesignWorkspaceModule } from './module';
type ServerBrief = {
version: number;
status: DesignBrief['status'];
medium: DesignBrief['medium'];
summary: string;
ready: boolean;
missing_decision: string | null;
};
type ServerQuote = {
quote_id: string;
status: DesignGenerationQuote['status'];
medium: DesignGenerationQuote['medium'];
brief_version: number;
brief_summary: string;
quoted_design_points: number;
expires_at: string;
};
type ServerMessage = {
role: DesignMessage['role'];
kind: DesignMessage['kind'];
text: string;
quick_replies: string[];
generation_quote: ServerQuote | null;
turn_revision: number;
created_at: string;
};
type ServerWorkspaceSummary = {
workspace_id: string;
title: string;
turn_revision: number;
view_revision: number;
phase: DesignWorkspaceSummary['phase'];
brief: ServerBrief;
updated_at: string;
};
type ServerWorkspace = ServerWorkspaceSummary & {
messages: ServerMessage[];
};
type ServerAsset = {
asset_id: string;
media_type: DesignAsset['mediaType'];
mime_type: string;
width: number;
height: number;
duration_milliseconds: number | null;
created_at: string;
};
type ServerTask = {
task_id: string;
workspace_id: string;
medium: DesignGenerationTask['medium'];
status: DesignGenerationTask['status'];
brief_version: number;
brief_summary: string;
quote_id: string | null;
quoted_design_points: number | null;
failure_code: string | null;
result_assets: ServerAsset[];
created_at: string;
updated_at: string;
};
type ServerErrorDetail = {
code?: unknown;
message?: unknown;
};
type WorksSquareDesignWorkspaceOptions = {
apiBaseUrl?: string;
fetchImpl?: typeof fetch;
};
function mapBrief(brief: ServerBrief): DesignBrief {
return {
version: brief.version,
status: brief.status,
medium: brief.medium,
summary: brief.summary,
ready: brief.ready,
missingDecision: brief.missing_decision,
};
}
function mapQuote(quote: ServerQuote | null): DesignGenerationQuote | null {
if (!quote) return null;
return {
quoteId: quote.quote_id,
status: quote.status,
medium: quote.medium,
briefVersion: quote.brief_version,
briefSummary: quote.brief_summary,
quotedDesignPoints: quote.quoted_design_points,
expiresAt: quote.expires_at,
};
}
function mapWorkspaceSummary(workspace: ServerWorkspaceSummary): DesignWorkspaceSummary {
return {
workspaceId: workspace.workspace_id,
title: workspace.title,
turnRevision: workspace.turn_revision,
viewRevision: workspace.view_revision,
phase: workspace.phase,
brief: mapBrief(workspace.brief),
updatedAt: workspace.updated_at,
};
}
function mapWorkspace(workspace: ServerWorkspace): DesignWorkspace {
return {
...mapWorkspaceSummary(workspace),
messages: workspace.messages.map((message, index) => ({
id: `${workspace.workspace_id}:${message.turn_revision}:${message.role}:${index}`,
role: message.role,
kind: message.kind,
text: message.text,
quickReplies: message.quick_replies,
generationQuote: mapQuote(message.generation_quote),
turnRevision: message.turn_revision,
createdAt: message.created_at,
})),
};
}
function mapTask(task: ServerTask): DesignGenerationTask {
return {
taskId: task.task_id,
workspaceId: task.workspace_id,
medium: task.medium,
status: task.status,
briefVersion: task.brief_version,
briefSummary: task.brief_summary,
quoteId: task.quote_id,
quotedDesignPoints: task.quoted_design_points,
failureCode: task.failure_code,
resultAssets: task.result_assets.map((asset) => ({
assetId: asset.asset_id,
workspaceId: task.workspace_id,
mediaType: asset.media_type,
mimeType: asset.mime_type,
width: asset.width,
height: asset.height,
durationMilliseconds: asset.duration_milliseconds,
createdAt: asset.created_at,
contentPath: designAssetContentPath(task.workspace_id, asset.asset_id),
})),
createdAt: task.created_at,
updatedAt: task.updated_at,
};
}
async function readPayload(response: Response): Promise<unknown> {
const text = await response.text();
if (!text.trim()) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
function asErrorDetail(payload: unknown): ServerErrorDetail {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return {};
const detail = (payload as Record<string, unknown>).detail;
if (detail && typeof detail === 'object' && !Array.isArray(detail)) {
return detail as ServerErrorDetail;
}
return payload as ServerErrorDetail;
}
function userFacingErrorMessage(code: string, fallback: string): string {
const messages: Record<string, string> = {
workspace_not_found: '设计项目不存在或无权访问',
workspace_revision_conflict: '设计项目已更新,请刷新后重试',
idempotency_conflict: '这次操作与已经提交的请求冲突',
reference_asset_invalid: '所选参考资产不可用',
generation_quote_expired: '当前生成方案已过期,请让 Agent 重新确认',
generation_quote_consumed: '当前生成方案已经确认过',
generation_quote_invalid: '当前生成方案已失效,请让 Agent 重新确认',
budget_denied: '当前设计点不足,无法开始生成',
policy_blocked: '当前内容不符合创作安全规则',
design_reasoner_unavailable: '设计 Agent 暂时不可用,请稍后重试',
design_runtime_unavailable: 'AI 设计服务暂时不可用',
design_production_unavailable: '当前生成能力暂时不可用',
};
return messages[code] ?? fallback;
}
export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
private readonly apiBaseUrl: string;
private readonly fetchImpl: typeof fetch;
constructor(options: WorksSquareDesignWorkspaceOptions = {}) {
this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? proxyAwareFetch;
}
async bootstrap(): Promise<DesignWorkspaceBootstrap> {
const [capabilities, workspaces] = await Promise.all([
this.getCapabilities(),
this.requestJson<ServerWorkspaceSummary[]>('/api/design/workspaces?limit=100&offset=0'),
]);
return {
capabilities,
workspaces: workspaces.map(mapWorkspaceSummary),
};
}
getCapabilities(): Promise<DesignCapabilities> {
return this.requestJson<DesignCapabilities>('/api/design/capabilities');
}
async createWorkspace(input: DesignCreateWorkspaceInput): Promise<DesignWorkspace> {
const workspace = await this.requestJson<ServerWorkspace>('/api/design/workspaces', {
method: 'POST',
body: JSON.stringify({
client_workspace_id: input.clientWorkspaceId,
title: input.title,
}),
});
return mapWorkspace(workspace);
}
async renameWorkspace(input: DesignRenameWorkspaceInput): Promise<DesignWorkspace> {
const workspace = await this.requestJson<ServerWorkspace>(
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}`,
{
method: 'PATCH',
body: JSON.stringify({ title: input.title }),
},
);
return mapWorkspace(workspace);
}
async getWorkspace(workspaceId: string): Promise<DesignWorkspace> {
const workspace = await this.requestJson<ServerWorkspace>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}`,
);
return mapWorkspace(workspace);
}
async submitMessage(input: DesignSubmitMessageInput): Promise<DesignWorkspace> {
const workspace = await this.requestJson<ServerWorkspace>(
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/turns`,
{
method: 'POST',
body: JSON.stringify({
client_turn_id: input.clientTurnId,
expected_turn_revision: input.expectedTurnRevision,
message: input.message,
attachment_asset_ids: input.attachmentAssetIds ?? [],
action: null,
}),
},
);
return mapWorkspace(workspace);
}
async confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignWorkspace> {
const workspace = await this.requestJson<ServerWorkspace>(
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/turns`,
{
method: 'POST',
body: JSON.stringify({
client_turn_id: input.clientTurnId,
expected_turn_revision: input.expectedTurnRevision,
message: '确认生成',
attachment_asset_ids: [],
action: {
type: 'confirm_generation',
quote_id: input.quoteId,
},
}),
},
);
return mapWorkspace(workspace);
}
async listTasks(workspaceId: string): Promise<DesignGenerationTask[]> {
const tasks = await this.requestJson<ServerTask[]>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/generation-tasks?limit=100&offset=0`,
);
return tasks.map(mapTask);
}
openAssetContent(workspaceId: string, assetId: string, range?: string): Promise<Response> {
return this.authorizedFetch(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(assetId)}/content`,
range ? { headers: { Range: range } } : {},
);
}
private async requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await this.authorizedFetch(path, {
...init,
headers: {
Accept: 'application/json',
...(init.body ? { 'Content-Type': 'application/json' } : {}),
...(init.headers ?? {}),
},
});
const payload = await readPayload(response);
if (!response.ok) {
const detail = asErrorDetail(payload);
const code = typeof detail.code === 'string'
? detail.code
: 'DESIGN_WORKSPACE_REQUEST_FAILED';
const fallback = typeof detail.message === 'string'
? detail.message
: `AI 设计请求失败(${response.status}`;
throw new DesignWorkspaceModuleError(
response.status,
code,
userFacingErrorMessage(code, fallback),
);
}
return payload as T;
}
private async authorizedFetch(path: string, init: RequestInit = {}): Promise<Response> {
let token = await getValidWorksSquareAccessToken({ fetchImpl: this.fetchImpl });
if (!token) {
throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计');
}
let response = await this.fetchWithToken(path, token, init);
if (response.status !== 401) return response;
token = await getValidWorksSquareAccessToken({
fetchImpl: this.fetchImpl,
forceRefresh: true,
});
if (!token) {
throw new DesignWorkspaceModuleError(401, 'AUTH_EXPIRED', '登录状态已失效,请重新登录');
}
response = await this.fetchWithToken(path, token, init);
return response;
}
private fetchWithToken(path: string, token: string, init: RequestInit): Promise<Response> {
return this.fetchImpl(`${this.apiBaseUrl}${path}`, {
...init,
headers: {
...init.headers,
Authorization: `Bearer ${token}`,
},
});
}
}