需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。 实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
553 lines
19 KiB
TypeScript
553 lines
19 KiB
TypeScript
import { createHash, randomUUID } from 'node:crypto';
|
||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||
import { dirname, join, resolve } from 'node:path';
|
||
import type {
|
||
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 = 2;
|
||
const LOCAL_WORKSPACE_DIRECTORY = 'image-workspace-development';
|
||
const LOCAL_WORKSPACE_FILE = 'design-workspace-v2.json';
|
||
const MAX_PROJECT_NAME_LENGTH = 80;
|
||
const MAX_MESSAGE_LENGTH = 4_000;
|
||
|
||
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;
|
||
workspaces: PersistedWorkspace[];
|
||
tasksByWorkspaceId: Record<string, DesignGenerationTask[]>;
|
||
assetsById: Record<string, PersistedAsset>;
|
||
};
|
||
|
||
type LocalImageWorkspaceOptions = {
|
||
userDataDir: string;
|
||
now?: () => Date;
|
||
createId?: () => string;
|
||
};
|
||
|
||
export class LocalImageWorkspaceError extends DesignWorkspaceModuleError {
|
||
constructor(status: number, code: string, message: string) {
|
||
super(status, code, message);
|
||
this.name = 'LocalImageWorkspaceError';
|
||
}
|
||
}
|
||
|
||
function createEmptyState(): PersistedImageWorkspace {
|
||
return {
|
||
schemaVersion: LOCAL_WORKSPACE_SCHEMA_VERSION,
|
||
workspaces: [],
|
||
tasksByWorkspaceId: {},
|
||
assetsById: {},
|
||
};
|
||
}
|
||
|
||
function clone<T>(value: T): T {
|
||
return JSON.parse(JSON.stringify(value)) as T;
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||
}
|
||
|
||
function isPersistedWorkspace(value: unknown): value is PersistedImageWorkspace {
|
||
return isRecord(value)
|
||
&& value.schemaVersion === LOCAL_WORKSPACE_SCHEMA_VERSION
|
||
&& Array.isArray(value.workspaces)
|
||
&& isRecord(value.tasksByWorkspaceId)
|
||
&& isRecord(value.assetsById);
|
||
}
|
||
|
||
function escapeXml(value: string): string {
|
||
return value
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
function messageHash(value: unknown): string {
|
||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||
}
|
||
|
||
function detectMedium(message: string): DesignMedium {
|
||
return /视频|动画|动效|镜头|转场/.test(message) ? 'video' : 'image';
|
||
}
|
||
|
||
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 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 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 个字符',
|
||
);
|
||
}
|
||
return message;
|
||
}
|
||
|
||
export function isLocalImageWorkspaceDevelopmentEnabled(options: {
|
||
isPackaged: boolean;
|
||
configuredMode?: string | null;
|
||
isDevelopmentServer?: boolean;
|
||
}): boolean {
|
||
if (options.isPackaged) return false;
|
||
const configuredMode = options.configuredMode?.trim().toLowerCase();
|
||
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 implements DesignWorkspaceModule {
|
||
private readonly rootDirectory: string;
|
||
private readonly stateFile: string;
|
||
private readonly now: () => Date;
|
||
private readonly createId: () => string;
|
||
private state: PersistedImageWorkspace | null = null;
|
||
private operation: Promise<void> = Promise.resolve();
|
||
|
||
constructor(options: LocalImageWorkspaceOptions) {
|
||
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;
|
||
}
|
||
|
||
async bootstrap(): Promise<DesignWorkspaceBootstrap> {
|
||
const state = await this.load();
|
||
return this.bootstrapView(state);
|
||
}
|
||
|
||
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 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.workspaces.unshift(workspace);
|
||
state.tasksByWorkspaceId[workspace.workspaceId] = [];
|
||
return workspace;
|
||
});
|
||
}
|
||
|
||
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;
|
||
});
|
||
}
|
||
|
||
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 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',
|
||
kind: 'user',
|
||
text: message,
|
||
quickReplies: [],
|
||
generationQuote: null,
|
||
turnRevision: nextRevision,
|
||
createdAt: timestamp,
|
||
},
|
||
{
|
||
id: `local-message-${this.createId()}`,
|
||
role: 'assistant',
|
||
kind: 'confirmation',
|
||
text: medium === 'video'
|
||
? '方向已整理为视频方案。确认后会创建视频生成任务,你也可以继续补充镜头、节奏或动效。'
|
||
: '方向已整理为图像方案。确认后会创建生图任务,你也可以继续调整构图、色彩或风格。',
|
||
quickReplies: ['确认生成', '继续调整'],
|
||
generationQuote: quote,
|
||
turnRevision: nextRevision,
|
||
createdAt: timestamp,
|
||
},
|
||
);
|
||
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;
|
||
});
|
||
}
|
||
|
||
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 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;
|
||
}
|
||
if (changed) await this.persist(state);
|
||
return visible;
|
||
});
|
||
}
|
||
|
||
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.bootstrapView(this.state);
|
||
});
|
||
}
|
||
|
||
private mutate(
|
||
operation: (state: PersistedImageWorkspace) => PersistedWorkspace,
|
||
): Promise<DesignWorkspace> {
|
||
return this.enqueue(async () => {
|
||
const state = await this.load();
|
||
const workspace = operation(state);
|
||
await this.persist(state);
|
||
return clone(workspace);
|
||
});
|
||
}
|
||
|
||
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||
const result = this.operation.then(operation, operation);
|
||
this.operation = result.then(() => undefined, () => undefined);
|
||
return result;
|
||
}
|
||
|
||
private async load(): Promise<PersistedImageWorkspace> {
|
||
if (this.state) return this.state;
|
||
try {
|
||
const parsed = JSON.parse(await readFile(this.stateFile, 'utf8')) as unknown;
|
||
if (!isPersistedWorkspace(parsed)) {
|
||
throw new LocalImageWorkspaceError(
|
||
500,
|
||
'IMAGE_WORKSPACE_LOCAL_DATA_INVALID',
|
||
'AI 设计本地数据暂时无法读取',
|
||
);
|
||
}
|
||
this.state = parsed;
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||
this.state = createEmptyState();
|
||
} else {
|
||
throw error;
|
||
}
|
||
}
|
||
return this.state;
|
||
}
|
||
|
||
private async persist(state: PersistedImageWorkspace): Promise<void> {
|
||
await mkdir(this.rootDirectory, { recursive: true });
|
||
const temporaryFile = `${this.stateFile}.${process.pid}.tmp`;
|
||
await writeFile(temporaryFile, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||
try {
|
||
await rename(temporaryFile, this.stateFile);
|
||
} catch (error) {
|
||
const code = (error as NodeJS.ErrnoException).code;
|
||
if (code !== 'EEXIST' && code !== 'EPERM') throw error;
|
||
await rm(this.stateFile, { force: true });
|
||
await rename(temporaryFile, this.stateFile);
|
||
}
|
||
this.state = state;
|
||
}
|
||
|
||
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 workspace;
|
||
}
|
||
|
||
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),
|
||
workspaces: state.workspaces.map(({ messages: _messages, clientWorkspaceId: _clientId, requestHashes: _requests, ...summary }) => clone(summary)),
|
||
};
|
||
}
|
||
}
|