From 3d9dd149188fc39f698d6885122099a671534b17 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 31 Jul 2026 13:56:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=20AI=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=20Workspace=20=E4=B8=8E=E7=94=9F=E6=88=90=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。 实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。 --- README.md | 13 +- electron/api/context.ts | 4 +- electron/api/route-utils.ts | 4 +- electron/api/routes/image-workspace.ts | 177 ++-- electron/image-workspace/local-workspace.ts | 648 ++++++++------ electron/image-workspace/module.ts | 35 + .../image-workspace/works-square-workspace.ts | 376 ++++++++ electron/main/index.ts | 7 +- electron/services/works-square-session.ts | 4 +- shared/image-workspace.ts | 176 ++-- .../layout/ImageWorkspaceSidebar.tsx | 237 +++-- src/lib/host-api.ts | 12 +- src/lib/image-workspace.ts | 178 ++-- src/pages/ImageCanvas/index.tsx | 806 ++++++++---------- src/stores/image-workspace.ts | 292 ++++--- tests/unit/image-canvas-page.test.tsx | 353 ++++---- tests/unit/image-workspace-api.test.ts | 139 +-- tests/unit/image-workspace-route.test.ts | 184 ++-- tests/unit/local-image-workspace.test.ts | 183 ++-- tests/unit/login-page.test.tsx | 19 +- tests/unit/sidebar-opencode-projects.test.tsx | 170 ++-- .../works-square-design-workspace.test.ts | 196 +++++ 22 files changed, 2506 insertions(+), 1707 deletions(-) create mode 100644 electron/image-workspace/module.ts create mode 100644 electron/image-workspace/works-square-workspace.ts create mode 100644 tests/unit/works-square-design-workspace.test.ts diff --git a/README.md b/README.md index 532d81d..9b7ddde 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版本为 `2.0.0`,包含两个已开通产品模块,并保留一个学习模块入口: - `Makelore Code|AI 编程`:管理本地项目、项目 Agent、会话、文件上下文、代码变更和运行时。 -- `Makelore Canvas|AI 绘画`:以项目、Agent 和对话组织云端视觉创作。 +- `Makelore Canvas|AI 绘画`:以设计项目(Workspace)组织 Agent 对话、方向确认、图片/视频生成任务和私有结果。 - `Makelore Learning|AI 学习`:数学与知识宇宙主题的学习入口,当前暂未开通;开屏页和模块切换菜单保留置灰入口,不能进入。 作品广场、素材广场、发布上传和云部署页面不属于 Makelore 2.0 工作台。项目成果预览 `/deliverables` 继续保留。 @@ -15,7 +15,7 @@ Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 - AI 编程运行时:Electron Main 管理项目内声明的 `opencode-ai` 依赖,Renderer 不直接启动或调用运行时。 - 后端边界:Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。 -- AI 绘画:生产环境使用云端工作区契约;上游不可用时展示明确的不可用状态。未打包开发环境可使用隔离的本地适配器。 +- AI 绘画:每个设计项目固定一个设计 Agent。Agent 先通过持续对话收敛 Brief,用户确认服务端 Quote 后才创建图片或视频任务;任务进度与对话互不阻塞,并在统一列表中展示。生产环境使用 Works Square 云端 Workspace 契约,上游不可用时明确报错。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 - 品牌资产:生产 SVG、PNG、应用图标、托盘图标与安装器视觉位于 [`resources/brand/`](resources/brand/README.md) 和 `resources/icons/`。 @@ -28,13 +28,13 @@ pnpm install --frozen-lockfile pnpm run dev ``` -开发环境中的 AI 绘画创作空间默认使用仅供开发的本地适配器;它会把项目数据保存在本机用户数据目录,并生成本地占位图。也可以使用下面的显式命令启动同一模式: +开发环境中的 AI 绘画创作空间默认使用仅供开发的本地适配器;它会把设计项目、对话、Quote 和生成任务保存在本机用户数据目录,并生成本地预览。也可以使用下面的显式命令启动同一模式: ```bash pnpm run dev:image-workspace:local ``` -该模式只允许在未打包应用中启用,使用独立开发数据,并保持与生产界面相同的工作区结构。打包应用和生产环境仍使用云端工作区契约。 +该模式只允许在未打包应用中启用,使用独立开发数据,并保持与生产相同的 Workspace-first 接口。打包应用和生产环境只使用云端适配器,云端失败不会回退到本地。 ## 质量检查 @@ -79,8 +79,9 @@ pnpm run package:linux - Renderer 不直接调用 Electron IPC 或本地运行时 HTTP 地址。 - Electron Main 负责认证、秘密存储、运行时生命周期、代理、同步和系统集成。 - AI 编程项目配置以项目内 `.niancode/project.json` 为准;项目文件和会话保持本地。 -- AI 绘画使用独立的云端工作区边界,不回退到 AI 编程项目数据。 -- Agent 配置是项目所有的;稳定 id 用于保持会话兼容,显示名称可以修改。 +- AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、snake_case 契约映射、结构化错误和私有媒体 Range 转发。 +- AI 绘画使用独立的云端 Workspace 边界,不回退到 AI 编程项目数据,也不向 Renderer 暴露 Provider、模型、Prompt、存储 URI 或远端登录 Token。 +- AI 编程的 Agent 配置是项目所有的;稳定 id 用于保持会话兼容,显示名称可以修改。AI 绘画的设计 Agent 是固定产品能力,不作为用户可增删的项目实体。 ### 项目联系人与会话 diff --git a/electron/api/context.ts b/electron/api/context.ts index aa3a3de..d5f899a 100644 --- a/electron/api/context.ts +++ b/electron/api/context.ts @@ -3,7 +3,7 @@ import type { OpencodeManager } from '../opencode/manager'; import type { OpencodeProjectStore } from '../opencode/project-store'; import type { HostEventBus } from './event-bus'; import type { createWorksCloudDeployment } from '../services/works-cloud-deployment'; -import type { LocalImageWorkspace } from '../image-workspace/local-workspace'; +import type { DesignWorkspaceModule } from '../image-workspace/module'; export type WorksCloudDeploymentCoordinator = ReturnType; @@ -13,5 +13,5 @@ export interface HostApiContext { eventBus: HostEventBus; mainWindow: BrowserWindow | null; worksCloudDeployment?: WorksCloudDeploymentCoordinator; - imageWorkspace?: LocalImageWorkspace; + imageWorkspace?: DesignWorkspaceModule; } diff --git a/electron/api/route-utils.ts b/electron/api/route-utils.ts index da3ff86..4fe1e22 100644 --- a/electron/api/route-utils.ts +++ b/electron/api/route-utils.ts @@ -25,7 +25,7 @@ export async function parseJsonBody(req: IncomingMessage): Promise { } /** - * Validate that mutation requests (POST/PUT/DELETE) carry a JSON Content-Type. + * Validate that mutation requests (POST/PUT/PATCH/DELETE) carry a JSON Content-Type. * This prevents "simple request" CSRF where the browser skips the preflight * when Content-Type is text/plain or application/x-www-form-urlencoded. */ @@ -51,7 +51,7 @@ export function setCorsHeaders(res: ServerResponse, origin?: string): void { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); } - res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-NianCode-Access-Token'); } diff --git a/electron/api/routes/image-workspace.ts b/electron/api/routes/image-workspace.ts index 352c4fe..d8332ae 100644 --- a/electron/api/routes/image-workspace.ts +++ b/electron/api/routes/image-workspace.ts @@ -1,24 +1,21 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; import { + IMAGE_WORKSPACE_API_PATH, IMAGE_WORKSPACE_UNAVAILABLE_CODE, IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE, - type ImageWorkspaceGenerationSettings, - type ImageWorkspaceReferenceUploadInput, - type ImageWorkspaceSendMessageInput, - type ImageWorkspaceSnapshot, + type DesignConfirmGenerationInput, + type DesignCreateWorkspaceInput, + type DesignRenameWorkspaceInput, + type DesignSubmitMessageInput, } from '../../../shared/image-workspace'; -import { LocalImageWorkspaceError } from '../../image-workspace/local-workspace'; +import { DesignWorkspaceModuleError } from '../../image-workspace/module'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; -const IMAGE_WORKSPACE_ROUTE_PREFIX = '/api/works/image-workspace'; - -function sendWorkspace(res: ServerResponse, workspace: ImageWorkspaceSnapshot): void { - sendJson(res, 200, { success: true, status: 200, workspace }); -} - function decodedSegments(pathname: string): string[] | null { - const suffix = pathname.slice(IMAGE_WORKSPACE_ROUTE_PREFIX.length).replace(/^\/+/, ''); + const suffix = pathname.slice(IMAGE_WORKSPACE_API_PATH.length).replace(/^\/+/, ''); if (!suffix) return []; try { return suffix.split('/').map((segment) => decodeURIComponent(segment)); @@ -31,26 +28,26 @@ function asString(value: unknown): string { return typeof value === 'string' ? value : ''; } +function asInteger(value: unknown): number { + return typeof value === 'number' && Number.isInteger(value) ? value : -1; +} + function asStringArray(value: unknown): string[] { return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : []; } -function asSettings(value: unknown): ImageWorkspaceGenerationSettings { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}; - const source = value as Record; - return { - modeId: asString(source.modeId) || undefined, - modelId: asString(source.modelId) || undefined, - aspectRatioId: asString(source.aspectRatioId) || undefined, - resolutionId: asString(source.resolutionId) || undefined, - outputCountId: asString(source.outputCountId) || undefined, - }; +function sendData(res: ServerResponse, data: unknown): void { + sendJson(res, 200, { success: true, status: 200, data }); } function sendRouteError(res: ServerResponse, error: unknown): void { - if (error instanceof LocalImageWorkspaceError) { + if (res.headersSent) { + res.destroy(error instanceof Error ? error : undefined); + return; + } + if (error instanceof DesignWorkspaceModuleError) { sendJson(res, error.status, { success: false, status: error.status, @@ -71,19 +68,51 @@ function sendRouteError(res: ServerResponse, error: unknown): void { sendJson(res, 500, { success: false, status: 500, - code: 'IMAGE_WORKSPACE_LOCAL_REQUEST_FAILED', - error: error instanceof Error ? error.message : '创作空间请求失败', + code: 'IMAGE_WORKSPACE_REQUEST_FAILED', + error: error instanceof Error ? error.message : 'AI 设计请求失败', }); } +async function relayAssetContent( + req: IncomingMessage, + res: ServerResponse, + ctx: HostApiContext, + workspaceId: string, + assetId: string, +): Promise { + const content = await ctx.imageWorkspace!.openAssetContent( + workspaceId, + assetId, + typeof req.headers.range === 'string' ? req.headers.range : undefined, + ); + res.statusCode = content.status; + for (const header of [ + 'accept-ranges', + 'cache-control', + 'content-length', + 'content-range', + 'content-type', + 'etag', + 'last-modified', + ]) { + const value = content.headers.get(header); + if (value) res.setHeader(header, value); + } + if (!content.body || req.method === 'HEAD') { + res.end(); + return; + } + await pipeline(Readable.fromWeb(content.body), res); +} + export async function handleImageWorkspaceRoutes( req: IncomingMessage, res: ServerResponse, url: URL, ctx: HostApiContext, ): Promise { - if (url.pathname !== IMAGE_WORKSPACE_ROUTE_PREFIX - && !url.pathname.startsWith(`${IMAGE_WORKSPACE_ROUTE_PREFIX}/`)) { + if (url.pathname !== IMAGE_WORKSPACE_API_PATH + && !url.pathname.startsWith(`${IMAGE_WORKSPACE_API_PATH}/`)) { return false; } @@ -99,55 +128,99 @@ export async function handleImageWorkspaceRoutes( const segments = decodedSegments(url.pathname); if (!segments) { - sendJson(res, 400, { success: false, status: 400, error: '无效的绘画空间路径' }); + sendJson(res, 400, { success: false, status: 400, error: '无效的 AI 设计路径' }); return true; } try { if (segments.length === 0 && req.method === 'GET') { - sendWorkspace(res, await ctx.imageWorkspace.getSnapshot()); + sendData(res, await ctx.imageWorkspace.bootstrap()); return true; } - if (segments.length === 1 && segments[0] === 'projects' && req.method === 'POST') { - const body = await parseJsonBody<{ name?: unknown }>(req); - sendWorkspace(res, await ctx.imageWorkspace.createProject(asString(body.name))); + if (segments.length === 1 && segments[0] === 'workspaces' && req.method === 'POST') { + const body = await parseJsonBody>(req); + const input: DesignCreateWorkspaceInput = { + clientWorkspaceId: asString(body.clientWorkspaceId), + title: asString(body.title), + }; + sendData(res, await ctx.imageWorkspace.createWorkspace(input)); return true; } if (segments.length === 1 && segments[0] === 'local-data' && req.method === 'DELETE') { - sendWorkspace(res, await ctx.imageWorkspace.reset()); + if (!ctx.imageWorkspace.reset) { + throw new DesignWorkspaceModuleError( + 405, + 'IMAGE_WORKSPACE_RESET_NOT_ALLOWED', + '云端 AI 设计不支持清空本地数据', + ); + } + sendData(res, await ctx.imageWorkspace.reset()); return true; } - if (segments.length === 3 && segments[0] === 'projects' && segments[2] === 'agents' && req.method === 'POST') { - sendWorkspace(res, await ctx.imageWorkspace.addAgent(segments[1])); + if (segments.length === 2 && segments[0] === 'workspaces' && req.method === 'GET') { + sendData(res, await ctx.imageWorkspace.getWorkspace(segments[1])); return true; } - if (segments.length === 3 && segments[0] === 'projects' && segments[2] === 'messages' && req.method === 'POST') { + if (segments.length === 2 && segments[0] === 'workspaces' && req.method === 'PATCH') { const body = await parseJsonBody>(req); - const input: ImageWorkspaceSendMessageInput = { - projectId: segments[1], - agentId: asString(body.agentId), - prompt: asString(body.prompt), - referenceImageIds: asStringArray(body.referenceImageIds), - settings: asSettings(body.settings), + const input: DesignRenameWorkspaceInput = { + workspaceId: segments[1], + title: asString(body.title), }; - sendWorkspace(res, await ctx.imageWorkspace.sendMessage(input)); + sendData(res, await ctx.imageWorkspace.renameWorkspace(input)); return true; } - if (segments.length === 3 && segments[0] === 'projects' && segments[2] === 'references' && req.method === 'POST') { + if (segments.length === 3 + && segments[0] === 'workspaces' + && segments[2] === 'messages' + && req.method === 'POST') { const body = await parseJsonBody>(req); - const input: ImageWorkspaceReferenceUploadInput = { - projectId: segments[1], - fileName: asString(body.fileName), - mimeType: asString(body.mimeType), - contentBase64: asString(body.contentBase64), + const input: DesignSubmitMessageInput = { + workspaceId: segments[1], + clientTurnId: asString(body.clientTurnId), + expectedTurnRevision: asInteger(body.expectedTurnRevision), + message: asString(body.message), + attachmentAssetIds: asStringArray(body.attachmentAssetIds), }; - const result = await ctx.imageWorkspace.uploadReference(input); - sendJson(res, 200, { success: true, status: 200, ...result }); + sendData(res, await ctx.imageWorkspace.submitMessage(input)); + return true; + } + + if (segments.length === 3 + && segments[0] === 'workspaces' + && segments[2] === 'tasks' + && req.method === 'GET') { + sendData(res, await ctx.imageWorkspace.listTasks(segments[1])); + return true; + } + + if (segments.length === 5 + && segments[0] === 'workspaces' + && segments[2] === 'quotes' + && segments[4] === 'confirm' + && req.method === 'POST') { + const body = await parseJsonBody>(req); + const input: DesignConfirmGenerationInput = { + workspaceId: segments[1], + quoteId: segments[3], + clientTurnId: asString(body.clientTurnId), + expectedTurnRevision: asInteger(body.expectedTurnRevision), + }; + sendData(res, await ctx.imageWorkspace.confirmGeneration(input)); + return true; + } + + if (segments.length === 5 + && segments[0] === 'workspaces' + && segments[2] === 'assets' + && segments[4] === 'content' + && (req.method === 'GET' || req.method === 'HEAD')) { + await relayAssetContent(req, res, ctx, segments[1], segments[3]); return true; } @@ -155,7 +228,7 @@ export async function handleImageWorkspaceRoutes( success: false, status: 404, code: 'IMAGE_WORKSPACE_ROUTE_NOT_FOUND', - error: `没有对应的绘画空间接口:${req.method ?? 'GET'} ${url.pathname}`, + error: `没有对应的 AI 设计接口:${req.method ?? 'GET'} ${url.pathname}`, }); } catch (error) { sendRouteError(res, error); diff --git a/electron/image-workspace/local-workspace.ts b/electron/image-workspace/local-workspace.ts index a319fd9..fa8dddb 100644 --- a/electron/image-workspace/local-workspace.ts +++ b/electron/image-workspace/local-workspace.ts @@ -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; +}; + +type PersistedAsset = { + workspaceId: string; + mimeType: string; + contentBase64: string; }; type PersistedImageWorkspace = { schemaVersion: typeof LOCAL_WORKSPACE_SCHEMA_VERSION; - activeProjectId: string | null; - projects: ImageWorkspaceProject[]; - referencesByProjectId: Record; + workspaces: PersistedWorkspace[]; + tasksByWorkspaceId: Record; + assetsById: Record; }; 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 { 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): { 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 [ + '', + '', + '', + '', + '', + '', + '', + `${escapeXml(title)}`, + `${escapeXml(summary.slice(0, 28))}`, + '本地开发预览 · AI Design Workspace', + '', + ].join(''); } -function normalizeSettings(settings: ImageWorkspaceGenerationSettings): Required { - 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, - 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) => ( - `${escapeXml(line)}` - )).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 = ` - - - - - CREATIVE · ${outputIndex + 1} - ${promptText} - ${escapeXml(metadata)} - - `.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 = 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 { - return this.enqueue(async () => this.snapshot(await this.load())); + async bootstrap(): Promise { + const state = await this.load(); + return this.bootstrapView(state); } - createProject(name: string): Promise { - 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 { + 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 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 { - 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 { + 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 { - 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(); - 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 { + 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 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 { + 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 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 { + 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.snapshot(this.state); + return this.bootstrapView(this.state); }); } - private async mutate( - operation: (state: PersistedImageWorkspace) => Promise, - ): Promise { + private mutate( + operation: (state: PersistedImageWorkspace) => PersistedWorkspace, + ): Promise { 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)), }; } } diff --git a/electron/image-workspace/module.ts b/electron/image-workspace/module.ts new file mode 100644 index 0000000..e180a95 --- /dev/null +++ b/electron/image-workspace/module.ts @@ -0,0 +1,35 @@ +import type { + DesignCapabilities, + DesignConfirmGenerationInput, + DesignCreateWorkspaceInput, + DesignGenerationTask, + DesignRenameWorkspaceInput, + DesignSubmitMessageInput, + DesignWorkspace, + DesignWorkspaceBootstrap, +} from '../../shared/image-workspace'; + +export class DesignWorkspaceModuleError extends Error { + readonly status: number; + readonly code: string; + + constructor(status: number, code: string, message: string) { + super(message); + this.name = 'DesignWorkspaceModuleError'; + this.status = status; + this.code = code; + } +} + +export interface DesignWorkspaceModule { + bootstrap(): Promise; + getCapabilities(): Promise; + createWorkspace(input: DesignCreateWorkspaceInput): Promise; + renameWorkspace(input: DesignRenameWorkspaceInput): Promise; + getWorkspace(workspaceId: string): Promise; + submitMessage(input: DesignSubmitMessageInput): Promise; + confirmGeneration(input: DesignConfirmGenerationInput): Promise; + listTasks(workspaceId: string): Promise; + openAssetContent(workspaceId: string, assetId: string, range?: string): Promise; + reset?(): Promise; +} diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts new file mode 100644 index 0000000..47d5321 --- /dev/null +++ b/electron/image-workspace/works-square-workspace.ts @@ -0,0 +1,376 @@ +import type { + DesignAsset, + DesignBrief, + DesignCapabilities, + DesignConfirmGenerationInput, + DesignCreateWorkspaceInput, + DesignGenerationQuote, + DesignGenerationTask, + DesignMessage, + DesignRenameWorkspaceInput, + DesignSubmitMessageInput, + DesignWorkspace, + DesignWorkspaceBootstrap, + DesignWorkspaceSummary, +} from '../../shared/image-workspace'; +import { designAssetContentPath } from '../../shared/image-workspace'; +import { WORKS_SQUARE_CONFIG } from '../api/works-config'; +import { proxyAwareFetch } from '../utils/proxy-fetch'; +import { getValidWorksSquareAccessToken } from '../services/works-square-session'; +import { DesignWorkspaceModuleError, type DesignWorkspaceModule } from './module'; + +type ServerBrief = { + version: number; + status: DesignBrief['status']; + medium: DesignBrief['medium']; + summary: string; + ready: boolean; + missing_decision: string | null; +}; + +type ServerQuote = { + quote_id: string; + status: DesignGenerationQuote['status']; + medium: DesignGenerationQuote['medium']; + brief_version: number; + brief_summary: string; + quoted_design_points: number; + expires_at: string; +}; + +type ServerMessage = { + role: DesignMessage['role']; + kind: DesignMessage['kind']; + text: string; + quick_replies: string[]; + generation_quote: ServerQuote | null; + turn_revision: number; + created_at: string; +}; + +type ServerWorkspaceSummary = { + workspace_id: string; + title: string; + turn_revision: number; + view_revision: number; + phase: DesignWorkspaceSummary['phase']; + brief: ServerBrief; + updated_at: string; +}; + +type ServerWorkspace = ServerWorkspaceSummary & { + messages: ServerMessage[]; +}; + +type ServerAsset = { + asset_id: string; + media_type: DesignAsset['mediaType']; + mime_type: string; + width: number; + height: number; + duration_milliseconds: number | null; + created_at: string; +}; + +type ServerTask = { + task_id: string; + workspace_id: string; + medium: DesignGenerationTask['medium']; + status: DesignGenerationTask['status']; + brief_version: number; + brief_summary: string; + quote_id: string | null; + quoted_design_points: number | null; + failure_code: string | null; + result_assets: ServerAsset[]; + created_at: string; + updated_at: string; +}; + +type ServerErrorDetail = { + code?: unknown; + message?: unknown; +}; + +type WorksSquareDesignWorkspaceOptions = { + apiBaseUrl?: string; + fetchImpl?: typeof fetch; +}; + +function mapBrief(brief: ServerBrief): DesignBrief { + return { + version: brief.version, + status: brief.status, + medium: brief.medium, + summary: brief.summary, + ready: brief.ready, + missingDecision: brief.missing_decision, + }; +} + +function mapQuote(quote: ServerQuote | null): DesignGenerationQuote | null { + if (!quote) return null; + return { + quoteId: quote.quote_id, + status: quote.status, + medium: quote.medium, + briefVersion: quote.brief_version, + briefSummary: quote.brief_summary, + quotedDesignPoints: quote.quoted_design_points, + expiresAt: quote.expires_at, + }; +} + +function mapWorkspaceSummary(workspace: ServerWorkspaceSummary): DesignWorkspaceSummary { + return { + workspaceId: workspace.workspace_id, + title: workspace.title, + turnRevision: workspace.turn_revision, + viewRevision: workspace.view_revision, + phase: workspace.phase, + brief: mapBrief(workspace.brief), + updatedAt: workspace.updated_at, + }; +} + +function mapWorkspace(workspace: ServerWorkspace): DesignWorkspace { + return { + ...mapWorkspaceSummary(workspace), + messages: workspace.messages.map((message, index) => ({ + id: `${workspace.workspace_id}:${message.turn_revision}:${message.role}:${index}`, + role: message.role, + kind: message.kind, + text: message.text, + quickReplies: message.quick_replies, + generationQuote: mapQuote(message.generation_quote), + turnRevision: message.turn_revision, + createdAt: message.created_at, + })), + }; +} + +function mapTask(task: ServerTask): DesignGenerationTask { + return { + taskId: task.task_id, + workspaceId: task.workspace_id, + medium: task.medium, + status: task.status, + briefVersion: task.brief_version, + briefSummary: task.brief_summary, + quoteId: task.quote_id, + quotedDesignPoints: task.quoted_design_points, + failureCode: task.failure_code, + resultAssets: task.result_assets.map((asset) => ({ + assetId: asset.asset_id, + workspaceId: task.workspace_id, + mediaType: asset.media_type, + mimeType: asset.mime_type, + width: asset.width, + height: asset.height, + durationMilliseconds: asset.duration_milliseconds, + createdAt: asset.created_at, + contentPath: designAssetContentPath(task.workspace_id, asset.asset_id), + })), + createdAt: task.created_at, + updatedAt: task.updated_at, + }; +} + +async function readPayload(response: Response): Promise { + const text = await response.text(); + if (!text.trim()) return null; + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} + +function asErrorDetail(payload: unknown): ServerErrorDetail { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return {}; + const detail = (payload as Record).detail; + if (detail && typeof detail === 'object' && !Array.isArray(detail)) { + return detail as ServerErrorDetail; + } + return payload as ServerErrorDetail; +} + +function userFacingErrorMessage(code: string, fallback: string): string { + const messages: Record = { + workspace_not_found: '设计项目不存在或无权访问', + workspace_revision_conflict: '设计项目已更新,请刷新后重试', + idempotency_conflict: '这次操作与已经提交的请求冲突', + reference_asset_invalid: '所选参考资产不可用', + generation_quote_expired: '当前生成方案已过期,请让 Agent 重新确认', + generation_quote_consumed: '当前生成方案已经确认过', + generation_quote_invalid: '当前生成方案已失效,请让 Agent 重新确认', + budget_denied: '当前设计点不足,无法开始生成', + policy_blocked: '当前内容不符合创作安全规则', + design_reasoner_unavailable: '设计 Agent 暂时不可用,请稍后重试', + design_runtime_unavailable: 'AI 设计服务暂时不可用', + design_production_unavailable: '当前生成能力暂时不可用', + }; + return messages[code] ?? fallback; +} + +export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { + private readonly apiBaseUrl: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: WorksSquareDesignWorkspaceOptions = {}) { + this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); + this.fetchImpl = options.fetchImpl ?? proxyAwareFetch; + } + + async bootstrap(): Promise { + const [capabilities, workspaces] = await Promise.all([ + this.getCapabilities(), + this.requestJson('/api/design/workspaces?limit=100&offset=0'), + ]); + return { + capabilities, + workspaces: workspaces.map(mapWorkspaceSummary), + }; + } + + getCapabilities(): Promise { + return this.requestJson('/api/design/capabilities'); + } + + async createWorkspace(input: DesignCreateWorkspaceInput): Promise { + const workspace = await this.requestJson('/api/design/workspaces', { + method: 'POST', + body: JSON.stringify({ + client_workspace_id: input.clientWorkspaceId, + title: input.title, + }), + }); + return mapWorkspace(workspace); + } + + async renameWorkspace(input: DesignRenameWorkspaceInput): Promise { + const workspace = await this.requestJson( + `/api/design/workspaces/${encodeURIComponent(input.workspaceId)}`, + { + method: 'PATCH', + body: JSON.stringify({ title: input.title }), + }, + ); + return mapWorkspace(workspace); + } + + async getWorkspace(workspaceId: string): Promise { + const workspace = await this.requestJson( + `/api/design/workspaces/${encodeURIComponent(workspaceId)}`, + ); + return mapWorkspace(workspace); + } + + async submitMessage(input: DesignSubmitMessageInput): Promise { + const workspace = await this.requestJson( + `/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/turns`, + { + method: 'POST', + body: JSON.stringify({ + client_turn_id: input.clientTurnId, + expected_turn_revision: input.expectedTurnRevision, + message: input.message, + attachment_asset_ids: input.attachmentAssetIds ?? [], + action: null, + }), + }, + ); + return mapWorkspace(workspace); + } + + async confirmGeneration(input: DesignConfirmGenerationInput): Promise { + const workspace = await this.requestJson( + `/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/turns`, + { + method: 'POST', + body: JSON.stringify({ + client_turn_id: input.clientTurnId, + expected_turn_revision: input.expectedTurnRevision, + message: '确认生成', + attachment_asset_ids: [], + action: { + type: 'confirm_generation', + quote_id: input.quoteId, + }, + }), + }, + ); + return mapWorkspace(workspace); + } + + async listTasks(workspaceId: string): Promise { + const tasks = await this.requestJson( + `/api/design/workspaces/${encodeURIComponent(workspaceId)}/generation-tasks?limit=100&offset=0`, + ); + return tasks.map(mapTask); + } + + openAssetContent(workspaceId: string, assetId: string, range?: string): Promise { + return this.authorizedFetch( + `/api/design/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(assetId)}/content`, + range ? { headers: { Range: range } } : {}, + ); + } + + private async requestJson(path: string, init: RequestInit = {}): Promise { + const response = await this.authorizedFetch(path, { + ...init, + headers: { + Accept: 'application/json', + ...(init.body ? { 'Content-Type': 'application/json' } : {}), + ...(init.headers ?? {}), + }, + }); + const payload = await readPayload(response); + if (!response.ok) { + const detail = asErrorDetail(payload); + const code = typeof detail.code === 'string' + ? detail.code + : 'DESIGN_WORKSPACE_REQUEST_FAILED'; + const fallback = typeof detail.message === 'string' + ? detail.message + : `AI 设计请求失败(${response.status})`; + throw new DesignWorkspaceModuleError( + response.status, + code, + userFacingErrorMessage(code, fallback), + ); + } + return payload as T; + } + + private async authorizedFetch(path: string, init: RequestInit = {}): Promise { + let token = await getValidWorksSquareAccessToken({ fetchImpl: this.fetchImpl }); + if (!token) { + throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计'); + } + + let response = await this.fetchWithToken(path, token, init); + if (response.status !== 401) return response; + + token = await getValidWorksSquareAccessToken({ + fetchImpl: this.fetchImpl, + forceRefresh: true, + }); + if (!token) { + throw new DesignWorkspaceModuleError(401, 'AUTH_EXPIRED', '登录状态已失效,请重新登录'); + } + response = await this.fetchWithToken(path, token, init); + return response; + } + + private fetchWithToken(path: string, token: string, init: RequestInit): Promise { + return this.fetchImpl(`${this.apiBaseUrl}${path}`, { + ...init, + headers: { + ...init.headers, + Authorization: `Bearer ${token}`, + }, + }); + } +} diff --git a/electron/main/index.ts b/electron/main/index.ts index 159e716..136ed88 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -70,6 +70,7 @@ import { isLocalImageWorkspaceDevelopmentEnabled, LocalImageWorkspace, } from '../image-workspace/local-workspace'; +import { WorksSquareDesignWorkspace } from '../image-workspace/works-square-workspace'; const WINDOWS_APP_USER_MODEL_ID = 'app.niancode.desktop'; const isE2EMode = process.env.NIANCODE_E2E === '1'; @@ -393,9 +394,11 @@ async function initialize(): Promise { }); const imageWorkspace = localImageWorkspaceEnabled ? new LocalImageWorkspace({ userDataDir: app.getPath('userData') }) - : undefined; - if (imageWorkspace) { + : new WorksSquareDesignWorkspace(); + if (localImageWorkspaceEnabled) { logger.info('AI painting workspace is using local development storage'); + } else { + logger.info('AI painting workspace is using the Works Square cloud contract'); } // Set application menu diff --git a/electron/services/works-square-session.ts b/electron/services/works-square-session.ts index e74979e..feeffd4 100644 --- a/electron/services/works-square-session.ts +++ b/electron/services/works-square-session.ts @@ -176,7 +176,7 @@ async function refreshWorksSquareSession( } export async function getValidWorksSquareAccessToken( - options: { fetchImpl?: typeof fetch; nowMs?: number } = {}, + options: { fetchImpl?: typeof fetch; nowMs?: number; forceRefresh?: boolean } = {}, ): Promise { const session = currentSession; if (!session) return null; @@ -186,7 +186,7 @@ export async function getValidWorksSquareAccessToken( session.expiresAt && session.expiresAt <= nowMs + TOKEN_REFRESH_SKEW_MS, ); - if (!nearExpiry) return session.accessToken; + if (!nearExpiry && !options.forceRefresh) return session.accessToken; if (!refreshPromise) { refreshPromise = refreshWorksSquareSession(options).finally(() => { diff --git a/shared/image-workspace.ts b/shared/image-workspace.ts index 8c7ebd0..25c39c1 100644 --- a/shared/image-workspace.ts +++ b/shared/image-workspace.ts @@ -1,96 +1,120 @@ export const IMAGE_WORKSPACE_UNAVAILABLE_CODE = 'IMAGE_WORKSPACE_UNAVAILABLE'; -export const IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE = '创作空间暂不可用'; +export const IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE = 'AI 设计暂不可用'; +export const IMAGE_WORKSPACE_API_PATH = '/api/works/image-workspace'; -export type ImageWorkspaceOption = { - id: string; - label: string; - description?: string; +export type DesignMedium = 'image' | 'video'; +export type DesignWorkspacePhase = 'shaping' | 'awaiting_confirmation' | 'blocked'; +export type DesignTaskStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; +export type DesignQuoteStatus = 'active' | 'consumed' | 'expired' | 'superseded'; + +export type DesignCapabilities = { + conversation: boolean; + generation: boolean; + image: boolean; + video: boolean; }; -export type ImageWorkspaceCapabilities = { - modes: ImageWorkspaceOption[]; - models: ImageWorkspaceOption[]; - aspectRatios: ImageWorkspaceOption[]; - resolutions: ImageWorkspaceOption[]; - outputCounts: ImageWorkspaceOption[]; - defaultModeId?: string; - defaultModelId?: string; - defaultAspectRatioId?: string; - defaultResolutionId?: string; - defaultOutputCountId?: string; - maxReferenceImages: number; - referenceUpload: { - enabled: boolean; - acceptedMimeTypes: string[]; - maxBytes?: number; - }; +export type DesignBrief = { + version: number; + status: 'draft' | 'ready' | 'confirmed'; + medium: DesignMedium | null; + summary: string; + ready: boolean; + missingDecision: string | null; }; -export type ImageWorkspaceAgent = { - id: string; - name: string; - avatarUrl?: string | null; +export type DesignGenerationQuote = { + quoteId: string; + status: DesignQuoteStatus; + medium: DesignMedium; + briefVersion: number; + briefSummary: string; + quotedDesignPoints: number; + expiresAt: string; }; -export type ImageWorkspaceImage = { +export type DesignMessage = { id: string; - url: string; - thumbnailUrl?: string | null; - alt?: string | null; -}; - -export type ImageWorkspaceMessageStatus = 'queued' | 'running' | 'succeeded' | 'failed'; - -export type ImageWorkspaceMessage = { - id: string; - role: 'user' | 'assistant' | 'system'; - agentId?: string | null; - text?: string | null; - images: ImageWorkspaceImage[]; - status?: ImageWorkspaceMessageStatus; - error?: string | null; + role: 'user' | 'assistant'; + kind: 'user' | 'reply' | 'choice' | 'confirmation' | 'safety_redirect' | 'failure'; + text: string; + quickReplies: string[]; + generationQuote: DesignGenerationQuote | null; + turnRevision: number; createdAt: string; }; -export type ImageWorkspaceProject = { - id: string; - name: string; - agents: ImageWorkspaceAgent[]; - activeAgentId?: string | null; - messages: ImageWorkspaceMessage[]; - updatedAt?: string | null; +export type DesignWorkspaceSummary = { + workspaceId: string; + title: string; + turnRevision: number; + viewRevision: number; + phase: DesignWorkspacePhase; + brief: DesignBrief; + updatedAt: string; }; -export type ImageWorkspaceSnapshot = { - capabilities: ImageWorkspaceCapabilities; - projects: ImageWorkspaceProject[]; - activeProjectId?: string | null; +export type DesignWorkspace = DesignWorkspaceSummary & { + messages: DesignMessage[]; }; -export type ImageWorkspaceGenerationSettings = { - modeId?: string; - modelId?: string; - aspectRatioId?: string; - resolutionId?: string; - outputCountId?: string; -}; - -export type ImageWorkspaceSendMessageInput = { - projectId: string; - agentId: string; - prompt: string; - referenceImageIds: string[]; - settings: ImageWorkspaceGenerationSettings; -}; - -export type ImageWorkspaceReferenceUploadInput = { - projectId: string; - fileName: string; +export type DesignAsset = { + assetId: string; + workspaceId: string; + mediaType: DesignMedium; mimeType: string; - contentBase64: string; + width: number; + height: number; + durationMilliseconds: number | null; + createdAt: string; + contentPath: string; }; -export type ImageWorkspaceReferenceUploadResult = { - workspace: ImageWorkspaceSnapshot; - reference: ImageWorkspaceImage; +export type DesignGenerationTask = { + taskId: string; + workspaceId: string; + medium: DesignMedium; + status: DesignTaskStatus; + briefVersion: number; + briefSummary: string; + quoteId: string | null; + quotedDesignPoints: number | null; + failureCode: string | null; + resultAssets: DesignAsset[]; + createdAt: string; + updatedAt: string; }; + +export type DesignWorkspaceBootstrap = { + capabilities: DesignCapabilities; + workspaces: DesignWorkspaceSummary[]; +}; + +export type DesignCreateWorkspaceInput = { + clientWorkspaceId: string; + title: string; +}; + +export type DesignRenameWorkspaceInput = { + workspaceId: string; + title: string; +}; + +export type DesignSubmitMessageInput = { + workspaceId: string; + clientTurnId: string; + expectedTurnRevision: number; + message: string; + attachmentAssetIds?: string[]; +}; + +export type DesignConfirmGenerationInput = { + workspaceId: string; + clientTurnId: string; + expectedTurnRevision: number; + quoteId: string; +}; + +export function designAssetContentPath(workspaceId: string, assetId: string): string { + return `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(assetId)}/content`; +} diff --git a/src/components/layout/ImageWorkspaceSidebar.tsx b/src/components/layout/ImageWorkspaceSidebar.tsx index 4cb8b52..9712099 100644 --- a/src/components/layout/ImageWorkspaceSidebar.tsx +++ b/src/components/layout/ImageWorkspaceSidebar.tsx @@ -1,35 +1,49 @@ import { useEffect, useState } from 'react'; -import { Bot, FolderKanban, Loader2, Plus, RefreshCw, X } from 'lucide-react'; +import { + FolderKanban, + Loader2, + Pencil, + Plus, + RefreshCw, + X, +} from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace'; import { cn } from '@/lib/utils'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; +import type { DesignWorkspaceSummary } from '../../../shared/image-workspace'; type ImageWorkspaceSidebarProps = { sidebarCollapsed: boolean; }; +type ProjectDialogState = + | { mode: 'create'; project: null } + | { mode: 'rename'; project: DesignWorkspaceSummary }; + export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSidebarProps) { const navigate = useNavigate(); const status = useImageWorkspaceStore((state) => state.status); - const snapshot = useImageWorkspaceStore((state) => state.snapshot); - const activeProjectId = useImageWorkspaceStore((state) => state.activeProjectId); - const activeAgentIds = useImageWorkspaceStore((state) => state.activeAgentIds); + const bootstrap = useImageWorkspaceStore((state) => state.bootstrap); + const activeWorkspaceId = useImageWorkspaceStore((state) => state.activeWorkspaceId); const workspaceError = useImageWorkspaceStore((state) => state.error); const load = useImageWorkspaceStore((state) => state.load); const createProject = useImageWorkspaceStore((state) => state.createProject); - const addAgent = useImageWorkspaceStore((state) => state.addAgent); + const renameProject = useImageWorkspaceStore((state) => state.renameProject); const selectProject = useImageWorkspaceStore((state) => state.selectProject); - const selectAgent = useImageWorkspaceStore((state) => state.selectAgent); const [projectsOpen, setProjectsOpen] = useState(true); - const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [dialog, setDialog] = useState(null); const [projectName, setProjectName] = useState(''); - const [createError, setCreateError] = useState(null); - const [creating, setCreating] = useState(false); - const [addingAgentProjectId, setAddingAgentProjectId] = useState(null); - const [agentError, setAgentError] = useState(null); + const [dialogError, setDialogError] = useState(null); + const [saving, setSaving] = useState(false); useEffect(() => { if (status === 'idle') void load(); @@ -38,8 +52,8 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba useEffect(() => { const openCreateDialog = () => { setProjectName(''); - setCreateError(null); - setCreateDialogOpen(true); + setDialogError(null); + setDialog({ mode: 'create', project: null }); }; window.addEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, openCreateDialog); return () => window.removeEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, openCreateDialog); @@ -47,49 +61,47 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba const openCreateDialog = () => { setProjectName(''); - setCreateError(null); - setCreateDialogOpen(true); + setDialogError(null); + setDialog({ mode: 'create', project: null }); }; - const closeCreateDialog = () => { - if (creating) return; - setCreateDialogOpen(false); - setCreateError(null); + const openRenameDialog = (project: DesignWorkspaceSummary) => { + setProjectName(project.title); + setDialogError(null); + setDialog({ mode: 'rename', project }); }; - const handleCreateProject = async () => { - const name = projectName.trim(); - if (!name) { - setCreateError('请输入项目名称'); + const closeDialog = () => { + if (saving) return; + setDialog(null); + setDialogError(null); + }; + + const saveProject = async () => { + const title = projectName.trim(); + if (!title) { + setDialogError('请输入项目名称'); return; } - setCreating(true); - setCreateError(null); + setSaving(true); + setDialogError(null); try { - await createProject(name); - setCreateDialogOpen(false); + if (dialog?.mode === 'rename') { + await renameProject(dialog.project.workspaceId, title); + } else { + await createProject(title); + } + setDialog(null); navigate('/image-canvas'); } catch (error) { - setCreateError(error instanceof Error ? error.message : String(error)); + setDialogError(error instanceof Error ? error.message : String(error)); } finally { - setCreating(false); + setSaving(false); } }; - const handleAddAgent = async (projectId: string) => { - setAddingAgentProjectId(projectId); - setAgentError(null); - try { - await addAgent(projectId); - } catch (error) { - setAgentError(error instanceof Error ? error.message : String(error)); - } finally { - setAddingAgentProjectId(null); - } - }; - - const handleSelectProject = (projectId: string) => { - selectProject(projectId); + const handleSelectProject = (workspaceId: string) => { + void selectProject(workspaceId); navigate('/image-canvas'); }; @@ -103,12 +115,12 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba 'motion-press flex min-h-9 w-full items-center gap-2 rounded-lg border-0 bg-transparent px-2 py-1.5 text-left text-sm font-medium text-foreground shadow-none transition-colors hover:bg-surface-subtle active:scale-[0.985]', sidebarCollapsed && 'justify-center px-0', )} - aria-label="新建项目" + aria-label="新建设计项目" onClick={openCreateDialog} disabled={status !== 'ready'} > - {!sidebarCollapsed && '新建项目'} + {!sidebarCollapsed && '新建设计项目'}
@@ -125,7 +137,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba {!sidebarCollapsed ? ( <> - 项目列表 + 设计项目 {projectsOpen ? '收起' : '展开'} ) : null} @@ -137,7 +149,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
- 正在读取创作空间 + 正在读取设计项目
) : null} @@ -147,7 +159,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba data-testid="sidebar-image-workspace-unavailable" className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-3 text-xs font-medium text-muted-foreground" > -

创作空间暂不可用

+

AI 设计暂不可用

{workspaceError ?

{workspaceError}

: null} - -
- {project.agents.map((agent) => ( - - ))} - +
+
); }) : null} - - {agentError ?

{agentError}

: null}
) : null}
- {createDialogOpen ? ( + {dialog ? ( { - if (!open) closeCreateDialog(); + if (!open) closeDialog(); }} > { - if (creating) event.preventDefault(); + if (saving) event.preventDefault(); }} >
- 新建项目 - 填写名称后,再手动添加需要的 Agent。 + + {dialog.mode === 'create' ? '新建设计项目' : '重命名设计项目'} + + + 对话、设计方向和生成任务都会归档在这个项目中。 +
@@ -277,38 +262,38 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba className="mt-4 space-y-3" onSubmit={(event) => { event.preventDefault(); - void handleCreateProject(); + void saveProject(); }} > { setProjectName(event.target.value); - setCreateError(null); + setDialogError(null); }} autoFocus - disabled={creating} + disabled={saving} className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 text-sm font-medium outline-none focus:ring-2 focus:ring-brand/20" /> - {createError ?

{createError}

: null} + {dialogError ?

{dialogError}

: null}
- +
) : null} - ); } diff --git a/src/lib/host-api.ts b/src/lib/host-api.ts index 5089bca..291b899 100644 --- a/src/lib/host-api.ts +++ b/src/lib/host-api.ts @@ -103,12 +103,18 @@ function resolveProxyErrorMessage(error: HostApiProxyResponse['error']): string } function createProxyHttpError(data: HostApiProxyData): Error { + const payload = typeof data.json === 'object' && data.json != null + ? data.json as Record + : null; const message = data.text - || (typeof data.json === 'object' && data.json != null && 'error' in data.json - ? String((data.json as Record).error) + || (payload && 'error' in payload + ? String(payload.error) : `HTTP ${data.status ?? 'unknown'}`); - return normalizeAppError(new Error(message), { status: data.status }); + return normalizeAppError(new Error(message), { + status: data.status, + ...(payload && typeof payload.code === 'string' ? { backendCode: payload.code } : {}), + }); } function parseUnifiedProxyResponse( diff --git a/src/lib/image-workspace.ts b/src/lib/image-workspace.ts index 3712f6d..4a3e2f8 100644 --- a/src/lib/image-workspace.ts +++ b/src/lib/image-workspace.ts @@ -1,24 +1,24 @@ import { AppError } from '@/lib/error-model'; -import { hostApiFetch } from '@/lib/host-api'; import { + ensureHostApiToken, + getHostApiBase, + hostApiFetch, +} from '@/lib/host-api'; +import { + IMAGE_WORKSPACE_API_PATH, IMAGE_WORKSPACE_UNAVAILABLE_CODE, IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE, - type ImageWorkspaceImage, - type ImageWorkspaceReferenceUploadInput, - type ImageWorkspaceReferenceUploadResult, - type ImageWorkspaceSendMessageInput, - type ImageWorkspaceSnapshot, + type DesignGenerationTask, + type DesignWorkspace, + type DesignWorkspaceBootstrap, } from '../../shared/image-workspace'; -const IMAGE_WORKSPACE_API_PATH = '/api/works/image-workspace'; - -type ImageWorkspaceEnvelope = { +type ImageWorkspaceEnvelope = { success?: boolean; status?: number; code?: string; error?: string; - workspace?: ImageWorkspaceSnapshot; - reference?: ImageWorkspaceImage; + data?: T; }; export const IMAGE_WORKSPACE_CREATE_PROJECT_EVENT = 'niancode:image-workspace:create-project'; @@ -35,6 +35,12 @@ export class ImageWorkspaceApiError extends Error { } } +function createClientId(prefix: string): string { + const id = globalThis.crypto?.randomUUID?.() + ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${prefix}-${id}`; +} + function getErrorStatus(error: unknown): number { if (error instanceof AppError && typeof error.details?.status === 'number') { return error.details.status; @@ -42,105 +48,119 @@ function getErrorStatus(error: unknown): number { return 502; } -async function requestImageWorkspace( - accessToken: string | null | undefined, - path: string, - init: RequestInit = {}, -): Promise { - const headers = accessToken?.trim() - ? { 'X-NianCode-Access-Token': accessToken.trim(), ...init.headers } - : init.headers; - let response: ImageWorkspaceEnvelope; +function getErrorCode(error: unknown, status: number): string { + if (error instanceof AppError && typeof error.details?.backendCode === 'string') { + return error.details.backendCode; + } + if (status === 501) return IMAGE_WORKSPACE_UNAVAILABLE_CODE; + return 'IMAGE_WORKSPACE_REQUEST_FAILED'; +} + +async function requestData(path: string, init: RequestInit = {}): Promise { + let response: ImageWorkspaceEnvelope; try { - response = await hostApiFetch(path, { - ...init, - ...(headers ? { headers } : {}), - }); + response = await hostApiFetch>(path, init); } catch (error) { const status = getErrorStatus(error); throw new ImageWorkspaceApiError( status, - status === 501 ? IMAGE_WORKSPACE_UNAVAILABLE_CODE : 'IMAGE_WORKSPACE_REQUEST_FAILED', + getErrorCode(error, status), error instanceof Error ? error.message : IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE, ); } - if (!response.success || !response.workspace) { + if (!response.success || response.data === undefined) { throw new ImageWorkspaceApiError( response.status ?? 502, response.code ?? 'IMAGE_WORKSPACE_REQUEST_FAILED', - response.error ?? '创作空间请求失败', + response.error ?? 'AI 设计请求失败', ); } - return response.workspace; + return response.data; } -export function fetchImageWorkspace(accessToken?: string | null): Promise { - return requestImageWorkspace(accessToken, IMAGE_WORKSPACE_API_PATH); +export function fetchImageWorkspace(): Promise { + return requestData(IMAGE_WORKSPACE_API_PATH); } export function createImageWorkspaceProject( - accessToken: string | null | undefined, - name: string, -): Promise { - return requestImageWorkspace(accessToken, `${IMAGE_WORKSPACE_API_PATH}/projects`, { + title: string, + clientWorkspaceId = createClientId('workspace'), +): Promise { + return requestData(`${IMAGE_WORKSPACE_API_PATH}/workspaces`, { method: 'POST', - body: JSON.stringify({ name: name.trim() }), + body: JSON.stringify({ + clientWorkspaceId, + title: title.trim(), + }), }); } -export function addImageWorkspaceAgent( - accessToken: string | null | undefined, - projectId: string, -): Promise { - return requestImageWorkspace( - accessToken, - `${IMAGE_WORKSPACE_API_PATH}/projects/${encodeURIComponent(projectId)}/agents`, - { method: 'POST', body: JSON.stringify({}) }, +export function renameImageWorkspaceProject( + workspaceId: string, + title: string, +): Promise { + return requestData( + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}`, + { + method: 'PATCH', + body: JSON.stringify({ title: title.trim() }), + }, + ); +} + +export function fetchImageWorkspaceProject(workspaceId: string): Promise { + return requestData( + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}`, ); } export function sendImageWorkspaceMessage( - accessToken: string | null | undefined, - input: ImageWorkspaceSendMessageInput, -): Promise { - return requestImageWorkspace( - accessToken, - `${IMAGE_WORKSPACE_API_PATH}/projects/${encodeURIComponent(input.projectId)}/messages`, - { method: 'POST', body: JSON.stringify(input) }, + workspaceId: string, + expectedTurnRevision: number, + message: string, +): Promise { + return requestData( + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/messages`, + { + method: 'POST', + body: JSON.stringify({ + clientTurnId: createClientId('turn'), + expectedTurnRevision, + message: message.trim(), + attachmentAssetIds: [], + }), + }, ); } -export function uploadImageWorkspaceReference( - accessToken: string | null | undefined, - input: ImageWorkspaceReferenceUploadInput, -): Promise { - const headers = accessToken?.trim() - ? { 'X-NianCode-Access-Token': accessToken.trim() } - : undefined; - return hostApiFetch( - `${IMAGE_WORKSPACE_API_PATH}/projects/${encodeURIComponent(input.projectId)}/references`, +export function confirmImageWorkspaceGeneration( + workspaceId: string, + expectedTurnRevision: number, + quoteId: string, +): Promise { + return requestData( + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/quotes/${encodeURIComponent(quoteId)}/confirm`, { method: 'POST', - ...(headers ? { headers } : {}), - body: JSON.stringify(input), + body: JSON.stringify({ + clientTurnId: createClientId('turn'), + expectedTurnRevision, + }), }, - ).then((response) => { - if (!response.success || !response.workspace || !response.reference) { - throw new ImageWorkspaceApiError( - response.status ?? 502, - response.code ?? 'IMAGE_WORKSPACE_REQUEST_FAILED', - response.error ?? '参考图上传失败', - ); - } - return { workspace: response.workspace, reference: response.reference }; - }).catch((error: unknown) => { - if (error instanceof ImageWorkspaceApiError) throw error; - const status = getErrorStatus(error); - throw new ImageWorkspaceApiError( - status, - status === 501 ? IMAGE_WORKSPACE_UNAVAILABLE_CODE : 'IMAGE_WORKSPACE_REQUEST_FAILED', - error instanceof Error ? error.message : IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE, - ); - }); + ); +} + +export function fetchImageWorkspaceTasks( + workspaceId: string, +): Promise { + return requestData( + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/tasks`, + ); +} + +export async function resolveImageWorkspaceAssetUrl(contentPath: string): Promise { + const token = await ensureHostApiToken(); + const separator = contentPath.includes('?') ? '&' : '?'; + return `${getHostApiBase()}${contentPath}${separator}token=${encodeURIComponent(token)}`; } diff --git a/src/pages/ImageCanvas/index.tsx b/src/pages/ImageCanvas/index.tsx index 60b5d61..fc33ca4 100644 --- a/src/pages/ImageCanvas/index.tsx +++ b/src/pages/ImageCanvas/index.tsx @@ -1,254 +1,237 @@ -import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'; +import { + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from 'react'; import { Bot, + Check, + Clock3, Cloud, + Film, ImageIcon, - ImagePlus, Loader2, Plus, RefreshCw, Send, - Settings2, Sparkles, - X, + WandSparkles, } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Select } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; -import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace'; +import { + IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, + resolveImageWorkspaceAssetUrl, +} from '@/lib/image-workspace'; import { cn } from '@/lib/utils'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { - ImageWorkspaceGenerationSettings, - ImageWorkspaceImage, - ImageWorkspaceMessage, - ImageWorkspaceOption, + DesignAsset, + DesignGenerationQuote, + DesignGenerationTask, + DesignMessage, + DesignTaskStatus, } from '../../../shared/image-workspace'; -function resolveOptionId( - currentId: string | undefined, - preferredId: string | undefined, - options: ImageWorkspaceOption[], -): string | undefined { - if (currentId && options.some((option) => option.id === currentId)) return currentId; - if (preferredId && options.some((option) => option.id === preferredId)) return preferredId; - return options[0]?.id; +const ACTIVE_TASK_STATUSES = new Set(['queued', 'running']); + +function taskStatusLabel(status: DesignTaskStatus): string { + if (status === 'queued') return '排队中'; + if (status === 'running') return '生成中'; + if (status === 'succeeded') return '已完成'; + if (status === 'cancelled') return '已取消'; + return '失败'; } -function messageStatusLabel(message: ImageWorkspaceMessage): string | null { - if (message.status === 'queued') return '排队中'; - if (message.status === 'running') return '生成中'; - if (message.status === 'failed') return '生成失败'; +function mediumLabel(medium: 'image' | 'video'): string { + return medium === 'video' ? '视频' : '图片'; +} + +function activeQuote(messages: DesignMessage[]): DesignGenerationQuote | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const quote = messages[index].generationQuote; + if (quote?.status === 'active') return quote; + } return null; } -function fileToBase64(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onerror = () => reject(reader.error ?? new Error('无法读取参考图')); - reader.onload = () => { - const result = typeof reader.result === 'string' ? reader.result : ''; - const separator = result.indexOf(','); - resolve(separator >= 0 ? result.slice(separator + 1) : result); - }; - reader.readAsDataURL(file); - }); -} +function AssetPreview({ asset }: { asset: DesignAsset }) { + const [url, setUrl] = useState(null); + + useEffect(() => { + let cancelled = false; + void resolveImageWorkspaceAssetUrl(asset.contentPath).then((nextUrl) => { + if (!cancelled) setUrl(nextUrl); + }); + return () => { + cancelled = true; + }; + }, [asset.contentPath]); + + if (!url) { + return ( +
+ +
+ ); + } + + if (asset.mimeType.startsWith('video/')) { + return ( +