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; }; type PersistedAsset = { workspaceId: string; mimeType: string; contentBase64: string; }; type PersistedImageWorkspace = { schemaVersion: typeof LOCAL_WORKSPACE_SCHEMA_VERSION; workspaces: PersistedWorkspace[]; tasksByWorkspaceId: Record; assetsById: Record; }; 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(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function isRecord(value: unknown): value is Record { 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 [ '', '', '', '', '', '', '', `${escapeXml(title)}`, `${escapeXml(summary.slice(0, 28))}`, '本地开发预览 · AI Design Workspace', '', ].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 = 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 { const state = await this.load(); return this.bootstrapView(state); } getSnapshot(): Promise { return this.bootstrap(); } async getCapabilities(): Promise { return clone(LOCAL_CAPABILITIES); } createWorkspace(input: DesignCreateWorkspaceInput): Promise { 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 { 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 { const state = await this.load(); return clone(this.requireWorkspace(state, workspaceId)); } submitMessage(input: DesignSubmitMessageInput): Promise { 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 { 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 { 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 { 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 { 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 { return this.enqueue(async () => { const state = await this.load(); const workspace = operation(state); await this.persist(state); return clone(workspace); }); } private enqueue(operation: () => Promise): Promise { const result = this.operation.then(operation, operation); this.operation = result.then(() => undefined, () => undefined); return result; } private async load(): Promise { 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 { 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)), }; } }