Files
makelore/electron/image-workspace/local-workspace.ts
brother7 3d9dd14918 feat: 统一 AI 设计 Workspace 与生成任务链路
需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。

实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
2026-07-31 13:56:55 +08:00

553 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
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',
'项目名称需为 180 个字符',
);
}
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',
'设计描述需为 14000 个字符',
);
}
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)),
};
}
}