feat: 统一 AI 设计 Workspace 与生成任务链路
需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。 实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
This commit is contained in:
@@ -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("'", ''');
|
||||
}
|
||||
|
||||
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',
|
||||
'项目名称需为 1–80 个字符',
|
||||
);
|
||||
}
|
||||
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',
|
||||
'设计描述需为 1–4000 个字符',
|
||||
);
|
||||
}
|
||||
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', '项目名称需为 1–80 个字符');
|
||||
}
|
||||
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', '创作描述需为 1–4000 个字符');
|
||||
}
|
||||
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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user