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, DesignConversation, DesignConversationSummary, DesignConfirmGenerationInput, DesignCreateConversationInput, DesignCreateWorkspaceInput, DesignGenerationQuote, DesignGenerationTask, DesignMedium, DesignRenameWorkspaceInput, DesignSubmitMessageInput, DesignWorkspace, DesignWorkspaceBootstrap, DesignWorkspaceSummary, } from '../../shared/image-workspace'; import { designAssetContentPath } from '../../shared/image-workspace'; import { DesignWorkspaceModuleError, type DesignWorkspaceModule } from './module'; const LOCAL_WORKSPACE_SCHEMA_VERSION = 3; const LOCAL_WORKSPACE_DIRECTORY = 'image-workspace-development'; const LOCAL_WORKSPACE_FILE = 'design-workspace-v3.json'; const LEGACY_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 = DesignWorkspaceSummary & { clientWorkspaceId: string; }; type PersistedConversation = DesignConversation & { clientConversationId: string; requestHashes: Record; }; type PersistedAsset = { workspaceId: string; mimeType: string; contentBase64: string; }; type PersistedImageWorkspace = { schemaVersion: typeof LOCAL_WORKSPACE_SCHEMA_VERSION; workspaces: PersistedWorkspace[]; conversationsByWorkspaceId: Record; tasksByWorkspaceId: Record; assetsById: Record; }; type LegacyPersistedWorkspace = { workspaceId: string; clientWorkspaceId: string; title: string; turnRevision: number; viewRevision: number; phase: DesignConversation['phase']; brief: DesignConversation['brief']; messages: DesignConversation['messages']; requestHashes: Record; updatedAt: string; }; type LegacyPersistedImageWorkspace = { schemaVersion: 2; workspaces: LegacyPersistedWorkspace[]; 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: [], conversationsByWorkspaceId: {}, 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.conversationsByWorkspaceId) && isRecord(value.tasksByWorkspaceId) && isRecord(value.assetsById); } function isLegacyPersistedWorkspace(value: unknown): value is LegacyPersistedImageWorkspace { return isRecord(value) && value.schemaVersion === 2 && 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 legacyStateFile: 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.legacyStateFile = join(this.rootDirectory, LEGACY_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.mutateWorkspace((state) => { const existing = state.workspaces.find( (workspace) => workspace.clientWorkspaceId === input.clientWorkspaceId, ); if (existing) return existing; const timestamp = this.now().toISOString(); const workspaceId = `local-workspace-${this.createId()}`; const workspace: PersistedWorkspace = { workspaceId, clientWorkspaceId: input.clientWorkspaceId, title: validateTitle(input.title), viewRevision: 0, conversationCount: 1, phase: 'shaping', updatedAt: timestamp, }; const conversation = this.createEmptyConversation( workspaceId, `local-conversation-${this.createId()}`, `local-client-conversation-${this.createId()}`, '新会话', timestamp, ); state.workspaces.unshift(workspace); state.conversationsByWorkspaceId[workspaceId] = [conversation]; state.tasksByWorkspaceId[workspaceId] = []; return workspace; }); } createConversation(input: DesignCreateConversationInput): Promise { return this.mutateConversation((state) => { const workspace = this.requireWorkspace(state, input.workspaceId); const conversations = state.conversationsByWorkspaceId[input.workspaceId] ?? []; const existing = conversations.find( (conversation) => conversation.clientConversationId === input.clientConversationId, ); if (existing) return existing; const timestamp = this.now().toISOString(); const conversation = this.createEmptyConversation( input.workspaceId, `local-conversation-${this.createId()}`, input.clientConversationId, input.title, timestamp, ); conversations.unshift(conversation); state.conversationsByWorkspaceId[input.workspaceId] = conversations; workspace.conversationCount = conversations.length; workspace.viewRevision += 1; workspace.updatedAt = timestamp; return conversation; }); } async getConversation( workspaceId: string, conversationId: string, ): Promise { const state = await this.load(); return clone(this.requireConversation(state, workspaceId, conversationId)); } renameWorkspace(input: DesignRenameWorkspaceInput): Promise { return this.mutateWorkspace((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 this.workspaceView(state, this.requireWorkspace(state, workspaceId)); } submitMessage(input: DesignSubmitMessageInput): Promise { return this.mutateConversation((state) => { const workspace = this.requireWorkspace(state, input.workspaceId); const conversation = this.requireConversation( state, input.workspaceId, input.conversationId, ); const message = validateMessage(input.message); const hash = messageHash(input); if (this.isRequestReplay(conversation, input.clientTurnId, hash)) return conversation; this.assertRevision(conversation, input.expectedTurnRevision); this.supersedeQuotes(conversation); const timestamp = this.now().toISOString(); const nextRevision = conversation.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(), }; conversation.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, }, ); conversation.turnRevision = nextRevision; conversation.phase = 'awaiting_confirmation'; conversation.brief = { version: nextRevision, status: 'ready', medium, summary: message, ready: true, missingDecision: null, }; conversation.updatedAt = timestamp; conversation.requestHashes[input.clientTurnId] = hash; workspace.phase = conversation.phase; workspace.viewRevision += 1; workspace.updatedAt = timestamp; return conversation; }); } confirmGeneration(input: DesignConfirmGenerationInput): Promise { return this.mutateConversation((state) => { const workspace = this.requireWorkspace(state, input.workspaceId); const conversation = this.requireConversation( state, input.workspaceId, input.conversationId, ); const hash = messageHash(input); if (this.isRequestReplay(conversation, input.clientTurnId, hash)) return conversation; this.assertRevision(conversation, input.expectedTurnRevision); const quote = conversation.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 = conversation.turnRevision + 1; conversation.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, }, ); conversation.turnRevision = nextRevision; conversation.phase = 'shaping'; conversation.brief = { ...conversation.brief, status: 'confirmed' }; conversation.updatedAt = timestamp; conversation.requestHashes[input.clientTurnId] = hash; workspace.phase = conversation.phase; workspace.viewRevision += 1; workspace.updatedAt = timestamp; state.tasksByWorkspaceId[workspace.workspaceId] ??= []; state.tasksByWorkspaceId[workspace.workspaceId].unshift({ taskId: `local-task-${this.createId()}`, workspaceId: workspace.workspaceId, conversationId: conversation.conversationId, 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 conversation; }); } private createEmptyConversation( workspaceId: string, conversationId: string, clientConversationId: string, title: string, timestamp: string, ): PersistedConversation { return { conversationId, clientConversationId, workspaceId, title: validateTitle(title), turnRevision: 0, phase: 'shaping', brief: { version: 0, status: 'draft', medium: null, summary: '正在建立作品的视觉方向', ready: false, missingDecision: '作品形式', }, messages: [], requestHashes: {}, createdAt: timestamp, updatedAt: timestamp, }; } 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 mutateWorkspace( operation: (state: PersistedImageWorkspace) => PersistedWorkspace, ): Promise { return this.enqueue(async () => { const state = await this.load(); const workspace = operation(state); await this.persist(state); return this.workspaceView(state, workspace); }); } private mutateConversation( operation: (state: PersistedImageWorkspace) => PersistedConversation, ): Promise { return this.enqueue(async () => { const state = await this.load(); const conversation = operation(state); await this.persist(state); return this.conversationView(conversation); }); } 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 = await this.loadLegacyState(); } else { throw error; } } return this.state; } private async loadLegacyState(): Promise { try { const parsed = JSON.parse(await readFile(this.legacyStateFile, 'utf8')) as unknown; if (!isLegacyPersistedWorkspace(parsed)) { throw new LocalImageWorkspaceError( 500, 'IMAGE_WORKSPACE_LOCAL_DATA_INVALID', 'AI 设计本地数据暂时无法读取', ); } const migrated = this.migrateLegacyState(parsed); await this.persist(migrated); return migrated; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return createEmptyState(); throw error; } } private migrateLegacyState(legacy: LegacyPersistedImageWorkspace): PersistedImageWorkspace { const migrated = createEmptyState(); migrated.assetsById = clone(legacy.assetsById); migrated.tasksByWorkspaceId = clone(legacy.tasksByWorkspaceId); for (const legacyWorkspace of legacy.workspaces) { const conversationId = `local-conversation-${this.createId()}`; const createdAt = legacyWorkspace.messages[0]?.createdAt ?? legacyWorkspace.updatedAt; const conversation: PersistedConversation = { conversationId, clientConversationId: `migrated-${legacyWorkspace.workspaceId}`, workspaceId: legacyWorkspace.workspaceId, title: '历史会话', turnRevision: legacyWorkspace.turnRevision, phase: legacyWorkspace.phase, brief: clone(legacyWorkspace.brief), messages: clone(legacyWorkspace.messages), requestHashes: clone(legacyWorkspace.requestHashes), createdAt, updatedAt: legacyWorkspace.updatedAt, }; migrated.workspaces.push({ workspaceId: legacyWorkspace.workspaceId, clientWorkspaceId: legacyWorkspace.clientWorkspaceId, title: legacyWorkspace.title, viewRevision: legacyWorkspace.viewRevision, conversationCount: 1, phase: legacyWorkspace.phase, updatedAt: legacyWorkspace.updatedAt, }); migrated.conversationsByWorkspaceId[legacyWorkspace.workspaceId] = [conversation]; migrated.tasksByWorkspaceId[legacyWorkspace.workspaceId] = ( migrated.tasksByWorkspaceId[legacyWorkspace.workspaceId] ?? [] ).map((task) => ({ ...task, conversationId })); } return migrated; } 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 requireConversation( state: PersistedImageWorkspace, workspaceId: string, conversationId: string, ): PersistedConversation { this.requireWorkspace(state, workspaceId); const conversation = (state.conversationsByWorkspaceId[workspaceId] ?? []) .find((item) => item.conversationId === conversationId); if (!conversation) { throw new LocalImageWorkspaceError(404, 'conversation_not_found', '设计会话不存在'); } return conversation; } private assertRevision( conversation: PersistedConversation, expectedRevision: number, ): void { if (conversation.turnRevision !== expectedRevision) { throw new LocalImageWorkspaceError( 409, 'conversation_revision_conflict', '设计会话已在其他位置更新,请刷新后重试', ); } } private isRequestReplay( conversation: PersistedConversation, clientTurnId: string, hash: string, ): boolean { const existing = conversation.requestHashes[clientTurnId]; if (!existing) return false; if (existing !== hash) { throw new LocalImageWorkspaceError( 409, 'idempotency_conflict', '这次操作与已经提交的请求冲突', ); } return true; } private supersedeQuotes(conversation: PersistedConversation): void { for (const message of conversation.messages) { if (message.generationQuote?.status === 'active') { message.generationQuote.status = 'superseded'; } } } private bootstrapView(state: PersistedImageWorkspace): DesignWorkspaceBootstrap { return { capabilities: clone(LOCAL_CAPABILITIES), workspaces: state.workspaces.map(({ clientWorkspaceId: _clientId, ...summary }) => ( clone(summary) )), }; } private workspaceView( state: PersistedImageWorkspace, workspace: PersistedWorkspace, ): DesignWorkspace { const { clientWorkspaceId: _clientId, ...summary } = workspace; return clone({ ...summary, conversationCount: state.conversationsByWorkspaceId[workspace.workspaceId]?.length ?? 0, conversations: (state.conversationsByWorkspaceId[workspace.workspaceId] ?? []) .map((conversation) => this.conversationSummaryView(conversation)), }); } private conversationSummaryView( conversation: PersistedConversation, ): DesignConversationSummary { const { clientConversationId: _clientId, requestHashes: _requestHashes, messages: _messages, ...summary } = conversation; return clone(summary); } private conversationView(conversation: PersistedConversation): DesignConversation { const { clientConversationId: _clientId, requestHashes: _requestHashes, ...view } = conversation; return clone(view); } }