From b0b5a602b501308a23eb27e2f51a5169b9e46b1e Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 30 Aug 2026 17:00:31 +0800 Subject: [PATCH] feat(canvas): cut over design agent to living form v2 --- ...over-4e7b9a2c__living-form-v2-authority.md | 77 + ...60830-design-v2-client-cutover-4e7b9a2c.md | 124 + AGENTS.md | 3 +- README.md | 21 +- electron/api/routes/image-workspace.ts | 310 +- electron/image-workspace/local-workspace.ts | 1120 -------- electron/image-workspace/module.ts | 44 +- .../image-workspace/works-square-workspace.ts | 2071 +++++--------- electron/main/index.ts | 19 +- electron/preload/index.ts | 7 - package.json | 3 +- scripts/run-cloud-image-workspace-dev.mjs | 25 - scripts/run-local-image-workspace-dev.mjs | 25 - shared/image-workspace.ts | 583 ++-- src/App.tsx | 14 +- .../layout/ImageWorkspaceSidebar.tsx | 771 ++--- src/lib/image-workspace.ts | 143 +- .../ImageCanvas/DesignConversationPane.tsx | 206 ++ src/pages/ImageCanvas/DesignFormPane.tsx | 893 ++++++ .../ImageCanvas/DesignProductionPanel.tsx | 487 ++++ src/pages/ImageCanvas/index.tsx | 2379 ++-------------- src/stores/image-workspace.ts | 1719 ++++------- src/types/electron.d.ts | 1 - tests/e2e/fixtures/electron.ts | 6 - .../e2e/image-workspace-conversations.spec.ts | 264 -- tests/e2e/image-workspace-v2.spec.ts | 455 +++ tests/fixtures/design-workspace-v2.ts | 272 ++ tests/setup.ts | 1 - tests/unit/app-module-provider-gate.test.tsx | 1 - tests/unit/image-canvas-page.test.tsx | 2079 +------------- tests/unit/image-workspace-api.test.ts | 387 +-- tests/unit/image-workspace-route.test.ts | 794 ++---- tests/unit/image-workspace-store.test.ts | 1441 ++-------- tests/unit/local-image-workspace.test.ts | 454 --- .../works-square-design-workspace.test.ts | 2516 ++--------------- 35 files changed, 5729 insertions(+), 13986 deletions(-) create mode 100644 .project-docs/10-decisions/proposals/20260830-design-v2-client-cutover-4e7b9a2c__living-form-v2-authority.md create mode 100644 .project-docs/30-worklog/tasks/20260830-design-v2-client-cutover-4e7b9a2c.md delete mode 100644 electron/image-workspace/local-workspace.ts delete mode 100644 scripts/run-cloud-image-workspace-dev.mjs delete mode 100644 scripts/run-local-image-workspace-dev.mjs create mode 100644 src/pages/ImageCanvas/DesignConversationPane.tsx create mode 100644 src/pages/ImageCanvas/DesignFormPane.tsx create mode 100644 src/pages/ImageCanvas/DesignProductionPanel.tsx delete mode 100644 tests/e2e/image-workspace-conversations.spec.ts create mode 100644 tests/e2e/image-workspace-v2.spec.ts create mode 100644 tests/fixtures/design-workspace-v2.ts delete mode 100644 tests/unit/local-image-workspace.test.ts diff --git a/.project-docs/10-decisions/proposals/20260830-design-v2-client-cutover-4e7b9a2c__living-form-v2-authority.md b/.project-docs/10-decisions/proposals/20260830-design-v2-client-cutover-4e7b9a2c__living-form-v2-authority.md new file mode 100644 index 0000000..52f9d6a --- /dev/null +++ b/.project-docs/10-decisions/proposals/20260830-design-v2-client-cutover-4e7b9a2c__living-form-v2-authority.md @@ -0,0 +1,77 @@ +# Proposal: Supersede AI Design multi-Conversation ownership with Living Form V2 + +## Source + +- Task: `20260830-design-v2-client-cutover-4e7b9a2c` +- Mode: Feature +- Status: Proposed for Integration Gate review + +## Decision Proposed + +Supersede ADR-001. One AI Design Workspace owns one current Direction, one +persistent Agent Gateway Session, one Current Design Specification, its +conversation timeline, immutable generation Quotes, generation Tasks, and +Assets. Conversation remains an interaction history, not a separately created +or selected authority object. + +The Living Form is a server projection of the Current Design Specification. A +chat turn, direct field edit, decision-prompt response, Agent proposal +acceptance, lock change, and restore action are inputs to the same reducer. +Renderer drafts are temporary buffers only. Scalar fields update explicitly; +identity-bearing collections are replaced atomically. Canonical revisions, +field decision resolution, provenance, locks, blockers, and recent changes come +only from the accepted server projection. + +Generation is a separate compile-and-confirm boundary. The server compiles a +professional image or video instruction and returns an immutable Quote tied to +one exact Specification revision. The client displays the customer-facing +output plan, warnings, expiry, and design points, then confirms only the Quote +identity. Provider Prompt, model, route, storage location, safety evidence, +compiler internals, and billing atoms remain server-private. + +Every mutation carries stable command and semantic operation identities. A +transport-unknown result keeps the exact command for safe replay; it is not +treated as a failed write and does not authorize a fresh generation operation. +Revision conflict refreshes canonical Workspace state while preserving local +drafts. Accepted writes have no client expiry. Event cursors are resumable, but +event transport order and Task progress never become Specification truth. + +Canvas has one production authority: the Works Square V2 boundary owned by +Electron Main. There is no V1 DTO adapter, nested design-Conversation UI, local +semantic adapter, editable final Prompt, mutable Quote, or cloud-failure +fallback. + +## Evidence + +- Shared V2 contract: `shared/image-workspace.ts`. +- Main adapter and Host boundary: + `electron/image-workspace/works-square-workspace.ts` and + `electron/api/routes/image-workspace.ts`. +- Renderer authority and interaction model: `src/stores/image-workspace.ts` and + `src/pages/ImageCanvas/`. +- V1 local authority removed: + `electron/image-workspace/local-workspace.ts` and both image-workspace dev + mode scripts are deleted. +- Verification recorded in the source task: full typecheck, lint with zero + errors, 1,425 passing unit tests, production Vite/Electron build, and two + passing Electron Canvas E2E tests. + +## Canonical Updates Requested + +- Mark ADR-001 superseded and add an accepted Living Form V2 ADR. +- Replace AI Design ownership, routing, Quote, task, asset, and local-adapter + sections in `20-architecture/system-overview.md`, `data-flow.md`, and + `module-map.md`. +- Reconcile the AI Design integrated snapshot, constraints, and follow-ups in + `30-worklog/current-state.md` after the matching server and client commits are + integrated. + +## Conflict And Confirmation + +This proposal intentionally reverses ADR-001 and canonical documentation that +requires one Workspace to contain multiple independent Conversations and lets +the client edit the final Prompt before mutable re-quoting. The user explicitly +approved the complete Living Form hard cutover, no low-version compatibility, +and customer-first generation confirmation in the source task. Integration +must preserve that provenance by superseding ADR-001 explicitly, not by editing +its historical decision in place. diff --git a/.project-docs/30-worklog/tasks/20260830-design-v2-client-cutover-4e7b9a2c.md b/.project-docs/30-worklog/tasks/20260830-design-v2-client-cutover-4e7b9a2c.md new file mode 100644 index 0000000..9d20420 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260830-design-v2-client-cutover-4e7b9a2c.md @@ -0,0 +1,124 @@ +# Task: Cut over Makelore Design Agent to V2 living form + +## Identity + +- Task ID: 20260830-design-v2-client-cutover-4e7b9a2c +- Mode: Feature +- Branch: codex/20260830-design-v2-client-cutover-4e7b9a2c-design-v2-client-cutover +- Worktree: D:\Datas\OthersProjects\makelore-design-v2-client-cutover-4e7b9a2c +- Base commit: f2456039230f68e3ecb2c8653fc3379c23bbe5ce +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Replace the Canvas V1 Brief/Conversation/Quote client contract with the V2 + Living Form, Design Specification, Direction, immutable Quote, Task, and Asset + projections delivered by Works Square and Agent Gateway. +- Create the form with the project, keep it visibly present throughout the + conversation, and let chat, direct edits, decision prompts, Quote request, and + confirmation update the same canonical projection. +- Replace `turn.submit`, mutable Quote PATCH, and `confirm_generation` action + shims with `design.input.apply`, `design.quote.request`, and + `design.generation.confirm` using stable command and semantic operation IDs. +- Reconcile assistant deltas, Direction snapshots, Workspace/Task events, local + edit buffers, and pending operations without treating transport sequence or + Task progress as Specification truth. +- Update the Main-owned cloud adapter, Host route, shared contracts, Renderer + store, Canvas page/sidebar, focused tests, E2E fixture, and README. Convert or + retire the V1-only local development adapter; it must not remain a second + semantic authority or a packaged fallback. + +## Intent And Constraints + +- The Form is a projection of the server-owned Current Design Specification, + never a second client store. Draft text and unsent direct edits remain local + buffers until accepted. +- The client does not expose provider prompts, model IDs, routes, internal cost, + storage URIs, safety evidence, or compiler internals as editable form state. +- Finite choices use explicit options; expressive design intent remains text or + identity-bearing collections. Provenance, resolution, locks, Suggestions, the + active Question, blockers, warnings, Quote summary, and recent changes remain + visible where useful. +- Renderer access continues through the Main-owned Host interface. Works token, + stream ticket, WebSocket, retry classification, and error redaction remain in + Electron Main. +- There is no low-version compatibility path or V1 DTO adapter. Client and server + authority move together after the server's one-time data gate succeeds. +- The product remains Chinese-only, cloud-only when packaged, and on the existing + Makelore light visual system. + +## Outcome + +- Replaced the V1 Canvas contract end to end with a V2 Workspace projection: + one persistent Living Form, one current Direction, one Agent Session, one + conversation timeline, immutable Quotes, Workspace Tasks, and Workspace + Assets. Shared DTOs, Main adapter, Host routes, Renderer API/store, Sidebar, + Canvas UI, and tests now use the same vocabulary. +- Added a persistent two-pane creator workspace. Conversation, direct scalar + edits, atomic collection edits, proposal acceptance, decision prompts, and + field locks all write through the same `design.input.apply` command and + reconcile from the server projection. Image/video-only sections are shown + progressively, while local field and chat drafts stay separate from + canonical state until accepted. +- Replaced mutable Prompt/Quote controls with server compilation and immutable + confirmation. The client displays only customer-facing output summary, + warnings, expiry, and design points; confirmation submits Quote identity and + never exposes provider Prompt, model, storage URI, or billing atoms. +- Added stable command and operation identities, preserved exact commands after + network-unknown outcomes, resumable event cursors, authoritative refresh on + revision conflict, Workspace task/asset reconciliation, and safe retry UI. +- Removed the V1 local semantic adapter, local/cloud mode switches, local + development scripts, nested Conversation navigation, and V1-only tests. Both + development and packaged builds now use the Main-owned Works Square V2 + boundary with no client semantic fallback. +- Updated README and development guidance to describe the shipped V2 behavior. + Replaced the old multi-Conversation Electron E2E with cloud-contract V2 + coverage for Living Form edits, conversation reconciliation, immutable Quote + confirmation, tasks, and Prompt Museum navigation. + +## Verification + +- `corepack pnpm typecheck` — passed. +- `corepack pnpm run lint:check` — passed with 0 errors; five pre-existing + warnings remain in unchanged `src/pages/Home/index.tsx` and + `src/pages/Makelore/index.tsx`. +- `corepack pnpm test` — passed: 181 test files, 1,425 tests passed and two + existing tests skipped. +- `corepack pnpm run build:vite` — passed for Renderer, Electron Main, Preload, + and utility worker; only existing Vite chunk/dynamic-import warnings remain. +- `corepack pnpm exec playwright test tests/e2e/image-workspace-v2.spec.ts + --workers=1` — passed: two Electron E2E tests. +- `git diff --check` — passed. + +## Follow-ups + +- Integrate the client commit only with the matching Works Square Design V2 + server contract. Any remaining server work must start from server `master` + `05bf994df944cec169be3f2378ee29b51aeae674` in a new isolated worktree; do not + replay changes onto the old `3d4d322` frontier. +- After server integration and deployment, run one real-account installed-client + smoke covering a direct field edit, conversational edit, Quote request, + confirmation, background completion, result download, and a deliberately + interrupted network-unknown retry. + +## Promotion Candidates + +- Target: accepted ADR-001 plus `.project-docs/20-architecture/{system-overview, + data-flow,module-map}.md` and the AI Design section of + `.project-docs/30-worklog/current-state.md`. + Proposal: supersede the multi-Conversation / mutable Brief and Quote model + with the one-Workspace, one-Direction, one-Living-Form V2 authority described + in `10-decisions/proposals/20260830-design-v2-client-cutover-4e7b9a2c__living-form-v2-authority.md`. + Evidence: this task's implementation, full unit suite, production build, and + Electron E2E results above. + Future impact: all client/server Design changes must treat chat and direct + edits as inputs to one Specification reducer, compile immutable Quotes on the + server, and preserve stable operation identity for uncertain writes. + Semantic conflicts: directly reverses ADR-001 and several canonical AI Design + rows that require nested Conversations, editable final Prompt, mutable Quote, + and a local development semantic adapter. + Human confirmation: already supplied in this source task through the explicit + request for the complete Living Form hard cutover and no low-version + compatibility; Integration Gate must still record ADR-001 as superseded rather + than silently rewriting it. diff --git a/AGENTS.md b/AGENTS.md index 35862e0..4293f9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,6 @@ Use the exact pnpm version pinned by `packageManager` in `package.json`. | --- | --- | | Install | `pnpm install --frozen-lockfile` | | Desktop development | `pnpm run dev` | -| Local image-workspace development | `pnpm run dev:image-workspace:local` | | Type check | `pnpm run typecheck` | | Lint check | `pnpm run lint:check` | | Unit tests | `pnpm test` | @@ -27,7 +26,7 @@ Use the exact pnpm version pinned by `packageManager` in `package.json`. - Do not add direct page/component IPC calls or direct local-runtime HTTP requests. - Electron Main owns runtime startup, Host API routes, authentication, providers, secrets, synchronization, proxies, updates, and system integration. - Keep AI programming project state local and project-scoped. -- Keep AI painting behind its Main-owned cloud workspace contract. The unpackaged local adapter is development-only and must never become a packaged fallback. +- Keep AI painting behind its Main-owned cloud V2 Workspace contract. One Workspace owns one persistent Living Form and Agent Session; Renderer chat and direct edits must use the same server reducer. Do not add a local semantic adapter or fallback. - Preserve internal `niancode` compatibility identifiers unless a separately approved migration covers app id, protocol, storage, environment, headers, backend contracts, and installed-user data. - Keep the product on the single Makelore light visual system. Do not add a dark-theme product mode. diff --git a/README.md b/README.md index 26e85f9..48adae2 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人的 AI 桌面工作台。当前版本为 `2.0.0`,包含四个已开通产品模块。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目 Agent、会话、文件上下文、代码变更和运行时。 -- `Makelore Canvas|AI 绘画`:以设计项目(Workspace)组织 Agent 对话、方向确认、文生图、单参考图生图、视频生成任务和私有结果;参考图可从当前项目作品选择或从本地上传。Canvas 侧栏在“新建设计项目”上方提供“获取灵感”,进入服务端驱动的提示词博物馆。 +- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。用户通过对话或直接编辑持续完善同一设计方向,再生成图片、单参考图作品或视频;参考素材可从当前项目作品选择或从本地上传。Canvas 侧栏提供“获取灵感”,进入服务端驱动的提示词博物馆。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 - `Makelore Learning|AI 学习`:浏览运营精选的学习项目,阅读项目 README,并把经过完整性校验的 ZIP 源码包保存到电脑继续实践。 @@ -24,11 +24,12 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - 客户端更新:Electron Main 按平台与架构选择更新源并保留原始诊断;设置页只显示一条脱敏后的中文状态。正式源缺少对应安装包时保持错误并允许重试,不会误报为已是最新版。 - 个人资料:姓名、年龄、性别与个人头像按账号同步到云端;首次登录进入模块选择页时会要求先完善姓名,首页欢迎语和主平台左下角账号区优先展示个人资料姓名;头像支持 PNG、JPEG、WebP,保存时自动居中裁剪并以圆形缩略图展示,未设置或加载失败时回退为姓名首字母。 - 会话观察同步:本地编程会话在一轮问答完成并进入空闲后,后台通过个人资料 PUT 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。 -- AI 绘画:每个设计项目固定一个设计 Agent,并可包含多条互相独立的设计会话。消息、Brief、Quote 和 `turnRevision` 属于 Conversation;图片/视频生成任务与资产属于 Workspace,切换或新建会话不会创建新项目,也不会隐藏项目任务。图片创作既支持文生图,也支持从当前项目已完成作品或本地上传中选择一张参考图继续生成;输入框支持直接上传参考图,上传后可在候选区点击或输入 `@` 选择,并将所选资产随消息提交;视频沿用同一单图选择器绑定首帧。每条 Conversation 复用服务端持久 Agent Gateway Session;已连接时 Agent 命令、流式回复和任务进度共用 WebSocket,断流时使用幂等 REST 提交与低频同步。确认栏展示并允许编辑服务端最终提示词和 generation options,分别展示清晰度、画幅和视频时长;每次修改都会按当前 Quote 重新报价、刷新设计点,报价完成前不能确认,确认时将最新提示词与参数原值提交给后端。客户端不计算百炼尺寸、供应商价格或积分价格,服务端 Quote 是唯一计费准则。确认结果会按 Quote 对账,即使 Run 在任务落库后异常结束,Workspace 任务仍会恢复到统一列表。生产环境使用 Works Square 云端 Workspace 契约,上游不可用时明确报错。 -- AI 绘画项目栏会在当前项目下保留会话历史,默认显示最近五条消息摘要和更新时间,更多会话可展开;新建或点击历史条目都在同一项目中切换并恢复完整对话。 -- AI 绘画项目支持从侧栏删除。确认删除时必须完整输入项目名称;删除后项目及其会话、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。服务端采用软删除,数据库记录和对象存储暂不物理清理。未提交的任务会被取消并释放预留积分,已提交或运行中的任务继续后台结算但对用户隐藏;删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。 +- AI 绘画:一个 Workspace 固定对应一个设计方向、一条 Agent Session 和一份持久 Living Form。左侧对话时间线与右侧表单不是两套状态:聊天提取、Agent 建议、用户直接编辑、建议采纳和字段锁定都提交到同一个服务端 reducer,并由方向版本、字段来源和决策状态形成权威投影。数组型规格按完整集合原子替换,图片与视频字段按当前媒介渐进展示。 +- AI 绘画生成:服务端从已确认规格编译专业图片或视频指令并返回不可变 Quote;客户端只展示媒介、画幅、数量、格式、时长、警告和设计点,不展示或改写供应商 Prompt、模型、价格原子或存储地址。确认时只提交 Quote 身份。图片可使用当前 Workspace 的上传素材或生成作品,视频可绑定已审核首帧;任务与资产始终属于 Workspace。 +- AI 绘画健壮性:Renderer 为每次命令生成稳定 operation id,网络结果未知时保留原命令供原样重试,不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE;断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。 +- AI 绘画项目栏只展示 Workspace,不再在项目下创建独立设计会话。删除时必须完整输入项目名称;删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。 - AI 学习:主区展示服务端分页项目卡片,详情页用安全 Markdown 渲染 README;原始 HTML 被禁用,Markdown 图片节点直接加载服务端校验后的无凭据 HTTPS URL,包括 SVG 和 Electron 支持的其他图片格式,不经过服务端下载、识别、转码或镜像。下载按钮打开系统保存对话框,Main 不按 `Content-Length`、声明字节数或客户端上限阻断下载,流式校验 SHA-256 与 ZIP 签名后原子保存。客户端不提供课程生成、课程播放器、本地课程库、Agent、ASR 或课堂 runtime。运营管理与接口字段见 [`docs/learning-project-catalog-server-contract.md`](docs/learning-project-catalog-server-contract.md)。 -- 提示词博物馆:只陈列经过审核的作品预览、Prompt、分类以及作者/来源/许可证信息,支持搜索、使用场景/风格/主体筛选和详情抽屉;“使用此 Prompt”只把原文带回当前 Canvas 会话输入框,不自动发送、不构成社区。列表和详情数据由服务端提供,客户端不打包数据集;服务端字段契约见 [`docs/prompt-museum-server-contract.md`](docs/prompt-museum-server-contract.md)。 +- 提示词博物馆:只陈列经过审核的作品预览、Prompt、分类以及作者/来源/许可证信息,支持搜索、使用场景/风格/主体筛选和详情抽屉;“使用此 Prompt”只把原文带回当前 Canvas 对话草稿,不自动发送、不构成社区。列表和详情数据由服务端提供,客户端不打包数据集;服务端字段契约见 [`docs/prompt-museum-server-contract.md`](docs/prompt-museum-server-contract.md)。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 - 字体系统:Renderer UI 内嵌 Inter Variable 与经过字符子集化的 Source Han Sans SC WOFF2,按字符范围统一中英文并保留系统中文字体 fallback;代码、路径和日志使用独立等宽字体。 - 界面语言:仅保留中文;系统语言和历史设置中的其他语言会自动归一为中文。 @@ -43,13 +44,7 @@ pnpm install --frozen-lockfile pnpm run dev ``` -`pnpm run dev` 默认使用 Works Square 云端 AI 绘画适配器,与打包应用保持一致。需要离线开发或调试本地 Workspace 契约时,可以显式启动仅供开发的本地适配器;它会把设计项目、对话、Quote 和生成任务保存在本机用户数据目录,并生成本地预览: - -```bash -pnpm run dev:image-workspace:local -``` - -该模式只允许在未打包应用中启用,使用独立开发数据,并保持与生产相同的 Workspace-first 接口。打包应用和生产环境只使用云端适配器,云端失败不会回退到本地。 +`pnpm run dev` 与打包应用都使用 Works Square 云端 AI 绘画 V2 契约。Canvas 不提供本地语义适配器;开发时应使用测试替身或连接相同的云端边界,云端失败不会回退到本地状态机。 ## 质量检查 @@ -98,7 +93,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - Works Square 原生密码与短信登录均沿 Renderer → Host API → Electron Main → Works Square 链路完成。登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复登录,连续 7 天未使用才清除会话并要求重新登录。刷新凭据始终只由 Electron Main 持有,并在正式安装包中通过系统受保护凭据存储加密落盘;可选的记住密码记录使用独立的 Main-owned 加密存储,退出登录不会清除它,只有成功的未勾选密码登录才清除旧记录。未打包开发版只在内存持有会话且禁用记住密码,避免未签名 Electron 调试进程触发 macOS 钥匙串。Renderer 现有的短效公开 access-token 会话快照与持久化保持不变(旧版升级迁移时仅暂存既有刷新凭据,Main 成功接管后立即删除),账号密码不进入 Renderer 持久状态。 - AI 编程发布只经过 Main-owned Host API:Renderer 仅提交本地项目标识、非敏感作品资料和有界封面 DTO;Main 持有源码快照、本地 npm/Vite 构建、精确产物预检、双归档、Works Token、版本生成、幂等重试和安全状态投影。发布构建同时提供 Main-owned `ReleaseJob` 的 start/progress/status/cancel 契约,同一项目串行执行并支持取消;异步 Job 的扫描、依赖安装、构建和双归档均在独立 `utilityProcess` 中以流式文件处理,Main 只接收进度、摘要和契约,旧的同步提交接口继续兼容已有客户端。首次项目 create 使用 `/api/projects/with-cover` multipart 原子写入资料与封面;已有项目只提交版本,状态竞态会固定失败并要求重新确认,不执行无条件 metadata PATCH 或封面替换。项目的 Vite config/plugins 会以当前桌面用户权限执行,因此该链路只适用于用户信任的本地项目,不是 sandbox。 - AI 编程项目配置以项目内 `.niancode/project.json` 为准;项目文件和会话主数据保持本地,问答观察快照按个人资料同步规则单向上行。 -- AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、Conversation 所属的服务端持久 Agent Session、单次 WebSocket ticket、双向命令/事件帧、断点续传与契约映射,并通过本机 Host API 的 SSE 投影同步任务状态。切换会话只重连对应流;注销或退出时关闭本地流并清除本机 Session-id 缓存,不删除服务端持久 Conversation Session。远端 Token 与 ticket 不进入 Renderer。 +- AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、Workspace 所属的持久 Agent Session、稳定命令身份、有界 Run 查询、可恢复事件订阅与契约映射,并通过本机 Host API 的 SSE 投影同步表单、任务和资产状态。切换 Workspace 只重连对应流;注销或退出时关闭本地流,不删除服务端持久 Session。远端 Token、Provider Prompt 与存储地址不进入 Renderer。 - AI 绘画使用独立的云端 Workspace 边界,不回退到 AI 编程项目数据,也不向 Renderer 暴露 Provider、模型、Prompt、存储 URI 或远端登录 Token。 - AI 学习只通过 Main-owned Host API 获取项目列表、README 详情和封面/历史媒体路径;Renderer 不持有 Works Token、对象存储地址、任意归档 URL 或本地文件路径。项目 ZIP 只允许同 Works origin 最多五跳重定向,重定向请求不携带 Bearer;下载结果仅向 Renderer 返回 `saved` 或 `cancelled`。README 不执行原始 HTML,仅 Markdown 图片节点可直接加载服务端校验后的无凭据 HTTPS URL。 - Prompt Museum 使用独立的 Main-owned Host API 代理;Renderer 只接收分页卡片、详情和服务端返回的图片地址,Works Square Token 只由 Main 持有。发布记录必须由服务端完成作者、来源、许可证和素材授权审核,模块不提供投稿、点赞、评论或排行榜。 diff --git a/electron/api/routes/image-workspace.ts b/electron/api/routes/image-workspace.ts index b321c24..956afa8 100644 --- a/electron/api/routes/image-workspace.ts +++ b/electron/api/routes/image-workspace.ts @@ -1,7 +1,7 @@ +import { randomUUID } from 'node:crypto'; import { createWriteStream } from 'node:fs'; import { rename, rm } from 'node:fs/promises'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import { randomUUID } from 'node:crypto'; import { basename, dirname, extname, join } from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; @@ -11,17 +11,14 @@ import { IMAGE_WORKSPACE_UNAVAILABLE_CODE, IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE, type DesignAssetUploadInput, - type DesignCreateConversationInput, - type DesignConfirmGenerationInput, + type DesignCommandInput, type DesignCreateWorkspaceInput, - type DesignGenerationParameters, type DesignRenameWorkspaceInput, - type DesignSubmitMessageInput, -} from '../../../shared/image-workspace'; -import { - DesignWorkspaceModuleError, + type DesignUserFieldOperation, + type DesignUserInput, type DesignWorkspaceEventSubscription, -} from '../../image-workspace/module'; +} from '../../../shared/image-workspace'; +import { DesignWorkspaceModuleError } from '../../image-workspace/module'; import type { HostApiContext } from '../context'; import { flushStreamingHeaders, @@ -40,44 +37,124 @@ function decodedSegments(pathname: string): string[] | null { } } -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 asRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } -function generationParametersFromBody(body: Record): DesignGenerationParameters { - return { - model: asString(body.model), - resolution: asString(body.resolution), - aspectRatio: asString(body.aspectRatio), - durationSeconds: body.durationSeconds === null - ? null - : typeof body.durationSeconds === 'number' && Number.isFinite(body.durationSeconds) - ? body.durationSeconds - : null, - }; +function asString(value: unknown): string { + return typeof value === 'string' ? value : ''; } -const DESIGN_IMAGE_UPLOAD_MIME_TYPES = new Set([ - 'image/jpeg', - 'image/png', - 'image/webp', -]); +function invalidCommand(message = 'AI 设计命令无效'): never { + throw new DesignWorkspaceModuleError(400, 'IMAGE_WORKSPACE_INVALID_COMMAND', message); +} + +function requiredString(value: unknown, field: string): string { + const result = asString(value).trim(); + if (!result) invalidCommand(`${field} 不能为空`); + return result; +} + +function nonNegativeInteger(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + invalidCommand(`${field} 必须是非负整数`); + } + return value; +} + +function decodeFieldOperation(value: unknown): DesignUserFieldOperation { + const operation = asRecord(value); + const kind = operation.kind; + const path = requiredString(operation.path, '字段路径'); + if (kind === 'set') { + if (!Object.hasOwn(operation, 'value')) invalidCommand('字段写入缺少值'); + return { kind, path, value: operation.value }; + } + if (kind === 'clear') { + const resolution = operation.resolution; + if (resolution !== undefined && resolution !== 'open' && resolution !== 'not_applicable') { + invalidCommand('字段清空状态无效'); + } + return { kind, path, ...(resolution === undefined ? {} : { resolution }) }; + } + if (kind === 'set_lock') { + if (typeof operation.locked !== 'boolean') invalidCommand('字段锁定状态无效'); + return { kind, path, locked: operation.locked }; + } + return invalidCommand('不支持的字段操作'); +} + +function decodeUserInput(value: unknown): DesignUserInput { + const input = asRecord(value); + if (input.kind === 'direct_edit') { + if (!Array.isArray(input.operations) || input.operations.length === 0) { + invalidCommand('直接编辑至少需要一个字段操作'); + } + return { kind: 'direct_edit', operations: input.operations.map(decodeFieldOperation) }; + } + if (input.kind === 'chat') { + return { kind: 'chat', message: requiredString(input.message, '消息') }; + } + if (input.kind === 'prompt_resolution') { + const action = input.action; + if (action !== 'select' && action !== 'reject') invalidCommand('决策动作无效'); + const optionId = asString(input.optionId).trim(); + if (action === 'select' && !optionId) invalidCommand('选择决策时必须提供选项'); + return { + kind: 'prompt_resolution', + promptId: requiredString(input.promptId, '决策问题'), + action, + ...(optionId ? { optionId } : {}), + }; + } + if (input.kind === 'restore') { + return { + kind: 'restore', + specificationRevisionId: requiredString( + input.specificationRevisionId, + '设计版本', + ), + }; + } + return invalidCommand('不支持的设计输入'); +} + +function decodeCommand(workspaceId: string, body: Record): DesignCommandInput { + const common = { + workspaceId, + sessionId: requiredString(body.sessionId, '会话'), + expectedDirectionRevision: nonNegativeInteger( + body.expectedDirectionRevision, + '设计方向版本', + ), + clientOperationId: requiredString(body.clientOperationId, '操作标识'), + }; + if (body.kind === 'apply_input') { + return { kind: 'apply_input', ...common, input: decodeUserInput(body.input) }; + } + if (body.kind === 'request_quote') { + return { + kind: 'request_quote', + ...common, + specificationRevision: nonNegativeInteger( + body.specificationRevision, + '设计规格版本', + ), + }; + } + if (body.kind === 'confirm_generation') { + return { + kind: 'confirm_generation', + ...common, + quoteId: requiredString(body.quoteId, '报价'), + }; + } + return invalidCommand('不支持的设计命令'); +} + +const DESIGN_IMAGE_UPLOAD_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const MAX_DESIGN_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024; const MAX_DESIGN_IMAGE_UPLOAD_BODY_BYTES = ( Math.ceil(MAX_DESIGN_IMAGE_UPLOAD_BYTES / 3) * 4 + 16 * 1024 @@ -164,15 +241,7 @@ function sendData(res: ServerResponse, data: unknown): void { } const ASSET_FILE_EXTENSIONS = new Set([ - '.gif', - '.jpeg', - '.jpg', - '.mov', - '.mp4', - '.png', - '.svg', - '.webm', - '.webp', + '.gif', '.jpeg', '.jpg', '.mov', '.mp4', '.png', '.svg', '.webm', '.webp', ]); function safeAssetFileName(value: string, assetId: string): string { @@ -320,17 +389,11 @@ async function relayWorkspaceEvents( res: ServerResponse, ctx: HostApiContext, workspaceId: string, - conversationId: string, + sessionId: string, + resumedAfterEventId?: string, ): Promise { - if (!ctx.imageWorkspace?.openWorkspaceEvents) { - throw new DesignWorkspaceModuleError( - 501, - 'DESIGN_EVENT_STREAM_UNAVAILABLE', - 'AI 设计任务实时状态暂时不可用', - ); - } const header = req.headers['last-event-id']; - const afterEventId = Array.isArray(header) ? header[0] : header; + const afterEventId = (Array.isArray(header) ? header[0] : header) ?? resumedAfterEventId; let subscription: DesignWorkspaceEventSubscription | null = null; let closed = false; const close = () => { @@ -340,9 +403,9 @@ async function relayWorkspaceEvents( }; res.once('close', close); try { - const opened = await ctx.imageWorkspace.openWorkspaceEvents({ + const opened = await ctx.imageWorkspace!.openWorkspaceEvents({ workspaceId, - conversationId, + sessionId, ...(afterEventId ? { afterEventId } : {}), }); if (closed) { @@ -361,9 +424,7 @@ async function relayWorkspaceEvents( if (!await writeStreamingChunk( res, `id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`, - )) { - return; - } + )) return; } if (!res.writableEnded) res.end(); } finally { @@ -408,25 +469,13 @@ export async function handleImageWorkspaceRoutes( 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), + clientWorkspaceId: requiredString(body.clientWorkspaceId, '客户端项目标识'), + title: requiredString(body.title, '项目名称'), }; sendData(res, await ctx.imageWorkspace.createWorkspace(input)); return true; } - if (segments.length === 1 && segments[0] === 'local-data' && req.method === 'DELETE') { - 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 === 2 && segments[0] === 'workspaces' && req.method === 'GET') { sendData(res, await ctx.imageWorkspace.getWorkspace(segments[1])); return true; @@ -441,7 +490,7 @@ export async function handleImageWorkspaceRoutes( const body = await parseJsonBody>(req); const input: DesignRenameWorkspaceInput = { workspaceId: segments[1], - title: asString(body.title), + title: requiredString(body.title, '项目名称'), }; sendData(res, await ctx.imageWorkspace.renameWorkspace(input)); return true; @@ -449,69 +498,25 @@ export async function handleImageWorkspaceRoutes( if (segments.length === 3 && segments[0] === 'workspaces' - && segments[2] === 'conversations' + && segments[2] === 'commands' && req.method === 'POST') { const body = await parseJsonBody>(req); - const input: DesignCreateConversationInput = { - workspaceId: segments[1], - clientConversationId: asString(body.clientConversationId), - title: asString(body.title), - }; - sendData(res, await ctx.imageWorkspace.createConversation(input)); - return true; - } - - if (segments.length === 4 - && segments[0] === 'workspaces' - && segments[2] === 'conversations' - && req.method === 'GET') { - const before = url.searchParams.get('before'); - sendData( - res, - before === null - ? await ctx.imageWorkspace.getConversation(segments[1], segments[3]) - : await ctx.imageWorkspace.getConversation(segments[1], segments[3], before), - ); - return true; - } - - if (segments.length === 5 - && segments[0] === 'workspaces' - && segments[2] === 'conversations' - && segments[4] === 'messages' - && req.method === 'POST') { - const body = await parseJsonBody>(req); - const input: DesignSubmitMessageInput = { - workspaceId: segments[1], - conversationId: segments[3], - clientTurnId: asString(body.clientTurnId), - expectedTurnRevision: asInteger(body.expectedTurnRevision), - message: asString(body.message), - attachmentAssetIds: asStringArray(body.attachmentAssetIds), - }; - sendData(res, await ctx.imageWorkspace.submitMessage(input)); + sendData(res, await ctx.imageWorkspace.submitCommand(decodeCommand(segments[1], body))); return true; } if (segments.length === 3 && segments[0] === 'workspaces' - && segments[2] === 'tasks' + && segments[2] === 'events' && req.method === 'GET') { - sendData(res, await ctx.imageWorkspace.listTasks(segments[1])); - return true; - } - - if (segments.length === 4 - && segments[0] === 'workspaces' - && segments[2] === 'generation-quotes' - && req.method === 'PATCH') { - const body = await parseJsonBody>(req); - sendData(res, await ctx.imageWorkspace.updateGenerationQuote({ - workspaceId: segments[1], - quoteId: segments[3], - finalPrompt: typeof body.finalPrompt === 'string' ? body.finalPrompt : '', - generationParameters: generationParametersFromBody(body), - })); + await relayWorkspaceEvents( + req, + res, + ctx, + segments[1], + requiredString(url.searchParams.get('sessionId'), '会话'), + asString(url.searchParams.get('afterEventId')).trim() || undefined, + ); return true; } @@ -519,47 +524,8 @@ export async function handleImageWorkspaceRoutes( && segments[0] === 'workspaces' && segments[2] === 'assets' && req.method === 'POST') { - if (!ctx.imageWorkspace.uploadAsset) { - throw new DesignWorkspaceModuleError( - 501, - IMAGE_WORKSPACE_UNAVAILABLE_CODE, - '当前环境暂不支持上传设计图片', - ); - } const body = await parseAssetUploadBody(req); - sendData( - res, - await ctx.imageWorkspace.uploadAsset(decodeAssetUpload(segments[1], body)), - ); - return true; - } - - if (segments.length === 5 - && segments[0] === 'workspaces' - && segments[2] === 'conversations' - && segments[4] === 'events' - && req.method === 'GET') { - await relayWorkspaceEvents(req, res, ctx, segments[1], segments[3]); - return true; - } - - if (segments.length === 7 - && segments[0] === 'workspaces' - && segments[2] === 'conversations' - && segments[4] === 'quotes' - && segments[6] === 'confirm' - && req.method === 'POST') { - const body = await parseJsonBody>(req); - const input: DesignConfirmGenerationInput = { - workspaceId: segments[1], - conversationId: segments[3], - quoteId: segments[5], - clientTurnId: asString(body.clientTurnId), - expectedTurnRevision: asInteger(body.expectedTurnRevision), - finalPrompt: typeof body.finalPrompt === 'string' ? body.finalPrompt : '', - generationParameters: generationParametersFromBody(asRecord(body.generationParameters)), - }; - sendData(res, await ctx.imageWorkspace.confirmGeneration(input)); + sendData(res, await ctx.imageWorkspace.uploadAsset(decodeAssetUpload(segments[1], body))); return true; } diff --git a/electron/image-workspace/local-workspace.ts b/electron/image-workspace/local-workspace.ts deleted file mode 100644 index 09b5ff2..0000000 --- a/electron/image-workspace/local-workspace.ts +++ /dev/null @@ -1,1120 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; -import { dirname, join, resolve } from 'node:path'; -import type { - DesignCapabilities, - DesignConversation, - DesignConversationSummary, - DesignConfirmGenerationInput, - DesignCreateConversationInput, - DesignCreateWorkspaceInput, - DesignDeleteWorkspaceResult, - DesignGenerationOption, - DesignGenerationOptions, - DesignGenerationParameters, - DesignGenerationQuote, - DesignGenerationQuoteUpdateInput, - DesignGenerationTask, - DesignMessage, - DesignMedium, - DesignRenameWorkspaceInput, - DesignSubmitMessageInput, - DesignWorkspace, - DesignWorkspaceBootstrap, - DesignWorkspaceSummary, -} from '../../shared/image-workspace'; -import { designAssetContentPath } from '../../shared/image-workspace'; -import { DesignWorkspaceModuleError, type DesignWorkspaceModule } from './module'; - -const LOCAL_WORKSPACE_SCHEMA_VERSION = 3; -const LOCAL_WORKSPACE_DIRECTORY = 'image-workspace-development'; -const LOCAL_WORKSPACE_FILE = 'design-workspace-v3.json'; -const LEGACY_LOCAL_WORKSPACE_FILE = 'design-workspace-v2.json'; -const MAX_PROJECT_NAME_LENGTH = 80; -const MAX_MESSAGE_LENGTH = 4_000; - -const LOCAL_GENERATION_OPTIONS: Record = { - image: { - models: [{ - value: 'local-image-preview', - label: '本地图片预览', - default: true, - disabled: false, - multiplier: 1, - }], - resolutions: [{ - value: '1024x1024', - label: '1024 × 1024', - default: true, - disabled: false, - multiplier: 1, - }], - durations: [], - aspectRatios: [ - { - value: '1:1', - label: '1:1', - default: true, - disabled: false, - multiplier: 1, - }, - { - value: '16:9', - label: '16:9', - default: false, - disabled: false, - multiplier: 1, - }, - ], - }, - video: { - models: [{ - value: 'local-video-preview', - label: '本地视频预览', - default: true, - disabled: false, - multiplier: 1, - }], - resolutions: [ - { - value: '720P', - label: '720P', - default: false, - disabled: false, - multiplier: 0.8, - }, - { - value: '1080P', - label: '1080P', - default: true, - disabled: false, - multiplier: 1, - }, - ], - durations: [ - { - value: 4, - label: '4 秒', - default: false, - disabled: false, - multiplier: 0.8, - }, - { - value: 6, - label: '6 秒', - default: true, - disabled: false, - multiplier: 1, - }, - { - value: 8, - label: '8 秒', - default: false, - disabled: true, - multiplier: 1.3, - }, - ], - aspectRatios: [ - { - value: '16:9', - label: '16:9', - default: true, - disabled: false, - multiplier: 1, - }, - { - value: '9:16', - label: '9:16', - default: false, - disabled: false, - multiplier: 1, - }, - ], - }, -}; - -const LOCAL_PRICING_SCHEMA = 'local-design-pricing-v1'; - -function defaultOption( - options: DesignGenerationOption[], -): DesignGenerationOption { - return options.find((option) => option.default && !option.disabled) - ?? options.find((option) => !option.disabled) - ?? options[0]!; -} - -function localGenerationParameters(medium: DesignMedium): DesignGenerationParameters { - const options = LOCAL_GENERATION_OPTIONS[medium]; - return { - model: defaultOption(options.models).value, - resolution: defaultOption(options.resolutions).value, - aspectRatio: defaultOption(options.aspectRatios).value, - durationSeconds: options.durations.length > 0 - ? defaultOption(options.durations).value - : null, - }; -} - -function localGenerationPricing( - medium: DesignMedium, - parameters: DesignGenerationParameters, -): number { - const options = LOCAL_GENERATION_OPTIONS[medium]; - const model = options.models.find((option) => option.value === parameters.model); - const resolution = options.resolutions.find((option) => option.value === parameters.resolution); - const aspectRatio = options.aspectRatios.find((option) => option.value === parameters.aspectRatio); - const duration = parameters.durationSeconds === null - ? null - : options.durations.find((option) => option.value === parameters.durationSeconds); - if (!model || model.disabled || !resolution || resolution.disabled - || !aspectRatio || aspectRatio.disabled - || (options.durations.length > 0 && (!duration || duration.disabled)) - || (options.durations.length === 0 && parameters.durationSeconds !== null)) { - throw new LocalImageWorkspaceError( - 422, - 'generation_option_invalid', - '当前生成参数不可用,请重新选择', - ); - } - const baseAmount = medium === 'video' ? 2 : 1; - return Math.ceil(baseAmount * model.multiplier * resolution.multiplier * (duration?.multiplier ?? 1)); -} - -function createLocalQuote( - medium: DesignMedium, - briefVersion: number, - briefSummary: string, - finalPrompt: string, - quoteId: string, - expiresAt: string, -): DesignGenerationQuote { - const generationParameters = localGenerationParameters(medium); - const amount = localGenerationPricing(medium, generationParameters); - return { - quoteId, - status: 'active', - medium, - briefVersion, - briefSummary, - finalPrompt, - promptMode: 'guided', - generationParameters, - generationOptions: clone(LOCAL_GENERATION_OPTIONS[medium]), - pricing: { - schema: LOCAL_PRICING_SCHEMA, - amount, - rounding: 'ceil', - }, - quotedDesignPoints: amount, - expiresAt, - }; -} - -function sameGenerationParameters( - left: DesignGenerationParameters, - right: DesignGenerationParameters, -): boolean { - return left.model === right.model - && left.resolution === right.resolution - && left.aspectRatio === right.aspectRatio - && left.durationSeconds === right.durationSeconds; -} - -function normalizePersistedGenerationQuotes( - state: PersistedImageWorkspace, -): PersistedImageWorkspace { - for (const conversations of Object.values(state.conversationsByWorkspaceId)) { - for (const conversation of conversations) { - for (const message of conversation.messages) { - const quote = message.generationQuote; - if (!quote || (quote.finalPrompt !== undefined - && quote.generationParameters?.aspectRatio !== undefined - && quote.generationOptions?.aspectRatios !== undefined - && quote.pricing)) continue; - const finalPrompt = quote.finalPrompt ?? quote.briefSummary; - const normalized = createLocalQuote( - quote.medium, - quote.briefVersion, - quote.briefSummary, - finalPrompt, - quote.quoteId, - quote.expiresAt, - ); - Object.assign(quote, { - ...normalized, - status: quote.status, - quotedDesignPoints: quote.quotedDesignPoints, - }); - } - } - } - return state; -} - -const LOCAL_CAPABILITIES: DesignCapabilities = { - conversation: true, - generation: true, - image: true, - video: true, -}; -const LOCAL_CONVERSATION_MESSAGE_PAGE_SIZE = 10; - -type PersistedWorkspace = DesignWorkspaceSummary & { - clientWorkspaceId: string; -}; - -type PersistedConversation = DesignConversation & { - clientConversationId: string; - requestHashes: Record; -}; - -type PersistedAsset = { - workspaceId: string; - mimeType: string; - contentBase64: string; -}; - -type PersistedImageWorkspace = { - schemaVersion: typeof LOCAL_WORKSPACE_SCHEMA_VERSION; - workspaces: PersistedWorkspace[]; - conversationsByWorkspaceId: Record; - tasksByWorkspaceId: Record; - assetsById: Record; -}; - -type LegacyPersistedWorkspace = { - workspaceId: string; - clientWorkspaceId: string; - title: string; - turnRevision: number; - viewRevision: number; - phase: DesignConversation['phase']; - brief: DesignConversation['brief']; - messages: DesignConversation['messages']; - requestHashes: Record; - updatedAt: string; -}; - -type LegacyPersistedImageWorkspace = { - schemaVersion: 2; - workspaces: LegacyPersistedWorkspace[]; - tasksByWorkspaceId: Record; - assetsById: Record; -}; - -type LocalImageWorkspaceOptions = { - userDataDir: string; - now?: () => Date; - createId?: () => string; -}; - -export class LocalImageWorkspaceError extends DesignWorkspaceModuleError { - constructor(status: number, code: string, message: string) { - super(status, code, message); - this.name = 'LocalImageWorkspaceError'; - } -} - -function createEmptyState(): PersistedImageWorkspace { - return { - schemaVersion: LOCAL_WORKSPACE_SCHEMA_VERSION, - workspaces: [], - conversationsByWorkspaceId: {}, - tasksByWorkspaceId: {}, - assetsById: {}, - }; -} - -function clone(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function isPersistedWorkspace(value: unknown): value is PersistedImageWorkspace { - return isRecord(value) - && value.schemaVersion === LOCAL_WORKSPACE_SCHEMA_VERSION - && Array.isArray(value.workspaces) - && isRecord(value.conversationsByWorkspaceId) - && isRecord(value.tasksByWorkspaceId) - && isRecord(value.assetsById); -} - -function isLegacyPersistedWorkspace(value: unknown): value is LegacyPersistedImageWorkspace { - return isRecord(value) - && value.schemaVersion === 2 - && Array.isArray(value.workspaces) - && isRecord(value.tasksByWorkspaceId) - && isRecord(value.assetsById); -} - -function escapeXml(value: string): string { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - -function messageHash(value: unknown): string { - return createHash('sha256').update(JSON.stringify(value)).digest('hex'); -} - -function latestMessagePreview(messages: DesignMessage[]): string | null { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const preview = messages[index]?.text.replace(/\s+/gu, ' ').trim(); - if (preview) return preview; - } - return null; -} - -function detectMedium(message: string): DesignMedium { - return /视频|动画|动效|镜头|转场/.test(message) ? 'video' : 'image'; -} - -function placeholderSvg(title: string, summary: string): string { - return [ - '', - '', - '', - '', - '', - '', - '', - `${escapeXml(title)}`, - `${escapeXml(summary.slice(0, 28))}`, - '本地开发预览 · AI Design Workspace', - '', - ].join(''); -} - -function validateTitle(value: string): string { - const title = value.trim(); - if (!title || title.length > MAX_PROJECT_NAME_LENGTH) { - throw new LocalImageWorkspaceError( - 400, - 'IMAGE_WORKSPACE_INVALID_PROJECT_NAME', - '项目名称需为 1–80 个字符', - ); - } - return title; -} - -function validateMessage(value: string): string { - const message = value.trim(); - if (!message || message.length > MAX_MESSAGE_LENGTH) { - throw new LocalImageWorkspaceError( - 400, - 'IMAGE_WORKSPACE_INVALID_MESSAGE', - '设计描述需为 1–4000 个字符', - ); - } - return message; -} - -export function isLocalImageWorkspaceDevelopmentEnabled(options: { - isPackaged: boolean; - configuredMode?: string | null; - isDevelopmentServer?: boolean; -}): boolean { - if (options.isPackaged) return false; - const configuredMode = options.configuredMode?.trim().toLowerCase(); - if (configuredMode === 'cloud') return false; - if (configuredMode === 'local') return true; - return options.isDevelopmentServer === true; -} - -export function getLocalImageWorkspaceDirectory(userDataDir: string): string { - return join(userDataDir, LOCAL_WORKSPACE_DIRECTORY); -} - -export class LocalImageWorkspace implements DesignWorkspaceModule { - private readonly rootDirectory: string; - private readonly stateFile: string; - private readonly legacyStateFile: string; - private readonly now: () => Date; - private readonly createId: () => string; - private state: PersistedImageWorkspace | null = null; - private operation: Promise = Promise.resolve(); - - constructor(options: LocalImageWorkspaceOptions) { - const userDataDirectory = resolve(options.userDataDir); - this.rootDirectory = resolve(getLocalImageWorkspaceDirectory(userDataDirectory)); - if (dirname(this.rootDirectory) !== userDataDirectory) { - throw new Error('Invalid local AI design workspace directory'); - } - this.stateFile = join(this.rootDirectory, LOCAL_WORKSPACE_FILE); - this.legacyStateFile = join(this.rootDirectory, LEGACY_LOCAL_WORKSPACE_FILE); - this.now = options.now ?? (() => new Date()); - this.createId = options.createId ?? randomUUID; - } - - async bootstrap(): Promise { - const state = await this.load(); - return this.bootstrapView(state); - } - - getSnapshot(): Promise { - return this.bootstrap(); - } - - async getCapabilities(): Promise { - return clone(LOCAL_CAPABILITIES); - } - - createWorkspace(input: DesignCreateWorkspaceInput): Promise { - return this.mutateWorkspace((state) => { - const existing = state.workspaces.find( - (workspace) => workspace.clientWorkspaceId === input.clientWorkspaceId, - ); - if (existing) return existing; - const timestamp = this.now().toISOString(); - const workspaceId = `local-workspace-${this.createId()}`; - const workspace: PersistedWorkspace = { - workspaceId, - clientWorkspaceId: input.clientWorkspaceId, - title: validateTitle(input.title), - viewRevision: 0, - conversationCount: 1, - phase: 'shaping', - updatedAt: timestamp, - }; - const conversation = this.createEmptyConversation( - workspaceId, - `local-conversation-${this.createId()}`, - `local-client-conversation-${this.createId()}`, - '新会话', - timestamp, - ); - state.workspaces.unshift(workspace); - state.conversationsByWorkspaceId[workspaceId] = [conversation]; - state.tasksByWorkspaceId[workspaceId] = []; - return workspace; - }); - } - - deleteWorkspace(workspaceId: string): Promise { - return this.enqueue(async () => { - const state = await this.load(); - this.requireWorkspace(state, workspaceId); - - const nextState = clone(state); - nextState.workspaces = nextState.workspaces.filter( - (workspace) => workspace.workspaceId !== workspaceId, - ); - delete nextState.conversationsByWorkspaceId[workspaceId]; - delete nextState.tasksByWorkspaceId[workspaceId]; - for (const [assetId, asset] of Object.entries(nextState.assetsById)) { - if (asset.workspaceId === workspaceId) delete nextState.assetsById[assetId]; - } - await this.persist(nextState); - return { workspaceId, deleted: true }; - }); - } - - createConversation(input: DesignCreateConversationInput): Promise { - return this.mutateConversation((state) => { - const workspace = this.requireWorkspace(state, input.workspaceId); - const conversations = state.conversationsByWorkspaceId[input.workspaceId] ?? []; - const existing = conversations.find( - (conversation) => conversation.clientConversationId === input.clientConversationId, - ); - if (existing) return existing; - const timestamp = this.now().toISOString(); - const conversation = this.createEmptyConversation( - input.workspaceId, - `local-conversation-${this.createId()}`, - input.clientConversationId, - input.title, - timestamp, - ); - conversations.unshift(conversation); - state.conversationsByWorkspaceId[input.workspaceId] = conversations; - workspace.conversationCount = conversations.length; - workspace.viewRevision += 1; - workspace.updatedAt = timestamp; - return conversation; - }); - } - - async getConversation( - workspaceId: string, - conversationId: string, - before?: string, - ): Promise { - const state = await this.load(); - return this.conversationView( - this.requireConversation(state, workspaceId, conversationId), - before, - ); - } - - renameWorkspace(input: DesignRenameWorkspaceInput): Promise { - return this.mutateWorkspace((state) => { - const workspace = this.requireWorkspace(state, input.workspaceId); - workspace.title = validateTitle(input.title); - workspace.viewRevision += 1; - workspace.updatedAt = this.now().toISOString(); - return workspace; - }); - } - - async getWorkspace(workspaceId: string): Promise { - const state = await this.load(); - return this.workspaceView(state, this.requireWorkspace(state, workspaceId)); - } - - submitMessage(input: DesignSubmitMessageInput): Promise { - return this.mutateConversation((state) => { - const workspace = this.requireWorkspace(state, input.workspaceId); - const conversation = this.requireConversation( - state, - input.workspaceId, - input.conversationId, - ); - const message = validateMessage(input.message); - const hash = messageHash(input); - if (this.isRequestReplay(conversation, input.clientTurnId, hash)) return conversation; - this.assertRevision(conversation, input.expectedTurnRevision); - this.supersedeQuotes(conversation); - - const timestamp = this.now().toISOString(); - const nextRevision = conversation.turnRevision + 1; - const medium = detectMedium(message); - const quote = createLocalQuote( - medium, - nextRevision, - message, - message, - `local-quote-${this.createId()}`, - new Date(this.now().getTime() + 15 * 60 * 1000).toISOString(), - ); - conversation.messages.push( - { - id: `local-message-${this.createId()}`, - role: 'user', - kind: 'user', - text: message, - quickReplies: [], - generationQuote: null, - turnRevision: nextRevision, - createdAt: timestamp, - }, - { - id: `local-message-${this.createId()}`, - role: 'assistant', - kind: 'confirmation', - text: medium === 'video' - ? '方向已整理为视频方案。确认后会创建视频生成任务,你也可以继续补充镜头、节奏或动效。' - : '方向已整理为图像方案。确认后会创建生图任务,你也可以继续调整构图、色彩或风格。', - quickReplies: ['确认生成', '继续调整'], - generationQuote: quote, - turnRevision: nextRevision, - createdAt: timestamp, - }, - ); - conversation.turnRevision = nextRevision; - conversation.phase = 'awaiting_confirmation'; - conversation.brief = { - version: nextRevision, - status: 'ready', - medium, - summary: message, - ready: true, - missingDecision: null, - }; - conversation.updatedAt = timestamp; - conversation.requestHashes[input.clientTurnId] = hash; - workspace.phase = conversation.phase; - workspace.viewRevision += 1; - workspace.updatedAt = timestamp; - return conversation; - }); - } - - updateGenerationQuote( - input: DesignGenerationQuoteUpdateInput, - ): Promise { - return this.enqueue(async () => { - const state = await this.load(); - const workspace = this.requireWorkspace(state, input.workspaceId); - const conversation = (state.conversationsByWorkspaceId[input.workspaceId] ?? []) - .find((candidate) => candidate.messages.some((message) => ( - message.generationQuote?.quoteId === input.quoteId - ))); - const quote = conversation?.messages - .map((message) => message.generationQuote) - .find((candidate) => candidate?.quoteId === input.quoteId); - if (!conversation || !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', '当前生成报价已过期'); - } - if (!input.finalPrompt.trim()) { - throw new LocalImageWorkspaceError(422, 'generation_prompt_invalid', '提示词不能为空'); - } - const amount = localGenerationPricing(quote.medium, input.generationParameters); - quote.finalPrompt = input.finalPrompt; - quote.generationParameters = clone(input.generationParameters); - quote.pricing = { - ...quote.pricing, - amount, - }; - quote.quotedDesignPoints = amount; - const timestamp = this.now().toISOString(); - conversation.updatedAt = timestamp; - workspace.viewRevision += 1; - workspace.updatedAt = timestamp; - await this.persist(state); - return clone(quote); - }); - } - - confirmGeneration(input: DesignConfirmGenerationInput): Promise { - return this.mutateConversation((state) => { - const workspace = this.requireWorkspace(state, input.workspaceId); - const conversation = this.requireConversation( - state, - input.workspaceId, - input.conversationId, - ); - const hash = messageHash(input); - if (this.isRequestReplay(conversation, input.clientTurnId, hash)) return conversation; - this.assertRevision(conversation, input.expectedTurnRevision); - const quote = conversation.messages - .map((message) => message.generationQuote) - .find((candidate) => candidate?.quoteId === input.quoteId); - if (!quote || quote.status !== 'active') { - throw new LocalImageWorkspaceError(409, 'generation_quote_invalid', '当前生成报价已失效'); - } - if (new Date(quote.expiresAt).getTime() <= this.now().getTime()) { - quote.status = 'expired'; - throw new LocalImageWorkspaceError(409, 'generation_quote_expired', '当前生成报价已过期'); - } - if (quote.finalPrompt !== input.finalPrompt - || !sameGenerationParameters(quote.generationParameters, input.generationParameters)) { - throw new LocalImageWorkspaceError( - 409, - 'generation_quote_changed', - '生成参数已变更,请等待重新报价完成', - ); - } - - quote.status = 'consumed'; - const timestamp = this.now().toISOString(); - const nextRevision = conversation.turnRevision + 1; - conversation.messages.push( - { - id: `local-message-${this.createId()}`, - role: 'user', - kind: 'user', - text: '确认生成', - quickReplies: [], - generationQuote: null, - turnRevision: nextRevision, - createdAt: timestamp, - }, - { - id: `local-message-${this.createId()}`, - role: 'assistant', - kind: 'confirmation', - text: `${quote.medium === 'video' ? '视频' : '图片'}任务已创建,可在右侧任务列表查看进度。`, - quickReplies: [], - generationQuote: null, - turnRevision: nextRevision, - createdAt: timestamp, - }, - ); - conversation.turnRevision = nextRevision; - conversation.phase = 'shaping'; - conversation.brief = { ...conversation.brief, status: 'confirmed' }; - conversation.updatedAt = timestamp; - conversation.requestHashes[input.clientTurnId] = hash; - workspace.phase = conversation.phase; - workspace.viewRevision += 1; - workspace.updatedAt = timestamp; - state.tasksByWorkspaceId[workspace.workspaceId] ??= []; - state.tasksByWorkspaceId[workspace.workspaceId].unshift({ - taskId: `local-task-${this.createId()}`, - workspaceId: workspace.workspaceId, - conversationId: conversation.conversationId, - medium: quote.medium, - status: 'queued', - briefVersion: quote.briefVersion, - briefSummary: quote.briefSummary, - quoteId: quote.quoteId, - quotedDesignPoints: quote.quotedDesignPoints, - failureCode: null, - resultAssets: [], - createdAt: timestamp, - updatedAt: timestamp, - }); - return conversation; - }); - } - - private createEmptyConversation( - workspaceId: string, - conversationId: string, - clientConversationId: string, - title: string, - timestamp: string, - ): PersistedConversation { - return { - conversationId, - clientConversationId, - workspaceId, - title: validateTitle(title), - turnRevision: 0, - phase: 'shaping', - brief: { - version: 0, - status: 'draft', - medium: null, - summary: '正在建立作品的视觉方向', - ready: false, - missingDecision: '作品形式', - }, - messages: [], - requestHashes: {}, - createdAt: timestamp, - updatedAt: timestamp, - }; - } - - listTasks(workspaceId: string): Promise { - return this.enqueue(async () => { - const state = await this.load(); - const workspace = this.requireWorkspace(state, workspaceId); - const tasks = state.tasksByWorkspaceId[workspaceId] ?? []; - const visible = clone(tasks); - let changed = false; - for (const task of tasks) { - if (task.status === 'queued') { - task.status = 'running'; - task.updatedAt = this.now().toISOString(); - workspace.viewRevision += 1; - workspace.updatedAt = task.updatedAt; - changed = true; - continue; - } - if (task.status !== 'running') continue; - const assetId = `local-asset-${this.createId()}`; - const svg = placeholderSvg(workspace.title, task.briefSummary); - const asset = { - assetId, - workspaceId, - mediaType: task.medium, - mimeType: 'image/svg+xml', - width: 1280, - height: 720, - durationMilliseconds: task.medium === 'video' ? 5_000 : null, - createdAt: this.now().toISOString(), - contentPath: designAssetContentPath(workspaceId, assetId), - }; - state.assetsById[assetId] = { - workspaceId, - mimeType: asset.mimeType, - contentBase64: Buffer.from(svg).toString('base64'), - }; - task.status = 'succeeded'; - task.resultAssets = [asset]; - task.updatedAt = asset.createdAt; - workspace.viewRevision += 1; - workspace.updatedAt = asset.createdAt; - changed = true; - } - if (changed) await this.persist(state); - return visible; - }); - } - - async openAssetContent( - workspaceId: string, - assetId: string, - _range?: string, - ): Promise { - const state = await this.load(); - this.requireWorkspace(state, workspaceId); - const asset = state.assetsById[assetId]; - if (!asset || asset.workspaceId !== workspaceId) { - throw new LocalImageWorkspaceError(404, 'asset_not_found', '设计资产不存在'); - } - const body = Buffer.from(asset.contentBase64, 'base64'); - return new Response(body, { - status: 200, - headers: { - 'Content-Type': asset.mimeType, - 'Content-Length': String(body.byteLength), - 'Cache-Control': 'private, max-age=60', - }, - }); - } - - reset(): Promise { - return this.enqueue(async () => { - await rm(this.rootDirectory, { recursive: true, force: true }); - this.state = createEmptyState(); - return this.bootstrapView(this.state); - }); - } - - private mutateWorkspace( - operation: (state: PersistedImageWorkspace) => PersistedWorkspace, - ): Promise { - return this.enqueue(async () => { - const state = await this.load(); - const workspace = operation(state); - await this.persist(state); - return this.workspaceView(state, workspace); - }); - } - - private mutateConversation( - operation: (state: PersistedImageWorkspace) => PersistedConversation, - ): Promise { - return this.enqueue(async () => { - const state = await this.load(); - const conversation = operation(state); - await this.persist(state); - return this.conversationView(conversation); - }); - } - - private enqueue(operation: () => Promise): Promise { - const result = this.operation.then(operation, operation); - this.operation = result.then(() => undefined, () => undefined); - return result; - } - - private async load(): Promise { - if (this.state) return this.state; - try { - const parsed = JSON.parse(await readFile(this.stateFile, 'utf8')) as unknown; - if (!isPersistedWorkspace(parsed)) { - throw new LocalImageWorkspaceError( - 500, - 'IMAGE_WORKSPACE_LOCAL_DATA_INVALID', - 'AI 设计本地数据暂时无法读取', - ); - } - this.state = normalizePersistedGenerationQuotes(parsed); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - this.state = await this.loadLegacyState(); - } else { - throw error; - } - } - return this.state; - } - - private async loadLegacyState(): Promise { - try { - const parsed = JSON.parse(await readFile(this.legacyStateFile, 'utf8')) as unknown; - if (!isLegacyPersistedWorkspace(parsed)) { - throw new LocalImageWorkspaceError( - 500, - 'IMAGE_WORKSPACE_LOCAL_DATA_INVALID', - 'AI 设计本地数据暂时无法读取', - ); - } - const migrated = this.migrateLegacyState(parsed); - normalizePersistedGenerationQuotes(migrated); - await this.persist(migrated); - return migrated; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return createEmptyState(); - throw error; - } - } - - private migrateLegacyState(legacy: LegacyPersistedImageWorkspace): PersistedImageWorkspace { - const migrated = createEmptyState(); - migrated.assetsById = clone(legacy.assetsById); - migrated.tasksByWorkspaceId = clone(legacy.tasksByWorkspaceId); - for (const legacyWorkspace of legacy.workspaces) { - const conversationId = `local-conversation-${this.createId()}`; - const createdAt = legacyWorkspace.messages[0]?.createdAt ?? legacyWorkspace.updatedAt; - const conversation: PersistedConversation = { - conversationId, - clientConversationId: `migrated-${legacyWorkspace.workspaceId}`, - workspaceId: legacyWorkspace.workspaceId, - title: '历史会话', - turnRevision: legacyWorkspace.turnRevision, - phase: legacyWorkspace.phase, - brief: clone(legacyWorkspace.brief), - messages: clone(legacyWorkspace.messages), - requestHashes: clone(legacyWorkspace.requestHashes), - createdAt, - updatedAt: legacyWorkspace.updatedAt, - }; - migrated.workspaces.push({ - workspaceId: legacyWorkspace.workspaceId, - clientWorkspaceId: legacyWorkspace.clientWorkspaceId, - title: legacyWorkspace.title, - viewRevision: legacyWorkspace.viewRevision, - conversationCount: 1, - phase: legacyWorkspace.phase, - updatedAt: legacyWorkspace.updatedAt, - }); - migrated.conversationsByWorkspaceId[legacyWorkspace.workspaceId] = [conversation]; - migrated.tasksByWorkspaceId[legacyWorkspace.workspaceId] = ( - migrated.tasksByWorkspaceId[legacyWorkspace.workspaceId] ?? [] - ).map((task) => ({ ...task, conversationId })); - } - return migrated; - } - - private async persist(state: PersistedImageWorkspace): Promise { - await mkdir(this.rootDirectory, { recursive: true }); - const temporaryFile = `${this.stateFile}.${process.pid}.tmp`; - await writeFile(temporaryFile, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); - try { - await rename(temporaryFile, this.stateFile); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'EEXIST' && code !== 'EPERM') throw error; - await rm(this.stateFile, { force: true }); - await rename(temporaryFile, this.stateFile); - } - this.state = state; - } - - private requireWorkspace( - state: PersistedImageWorkspace, - workspaceId: string, - ): PersistedWorkspace { - const workspace = state.workspaces.find((item) => item.workspaceId === workspaceId); - if (!workspace) { - throw new LocalImageWorkspaceError(404, 'workspace_not_found', '设计项目不存在'); - } - return workspace; - } - - private requireConversation( - state: PersistedImageWorkspace, - workspaceId: string, - conversationId: string, - ): PersistedConversation { - this.requireWorkspace(state, workspaceId); - const conversation = (state.conversationsByWorkspaceId[workspaceId] ?? []) - .find((item) => item.conversationId === conversationId); - if (!conversation) { - throw new LocalImageWorkspaceError(404, 'conversation_not_found', '设计会话不存在'); - } - return conversation; - } - - private assertRevision( - conversation: PersistedConversation, - expectedRevision: number, - ): void { - if (conversation.turnRevision !== expectedRevision) { - throw new LocalImageWorkspaceError( - 409, - 'conversation_revision_conflict', - '设计会话已在其他位置更新,请刷新后重试', - ); - } - } - - private isRequestReplay( - conversation: PersistedConversation, - clientTurnId: string, - hash: string, - ): boolean { - const existing = conversation.requestHashes[clientTurnId]; - if (!existing) return false; - if (existing !== hash) { - throw new LocalImageWorkspaceError( - 409, - 'idempotency_conflict', - '这次操作与已经提交的请求冲突', - ); - } - return true; - } - - private supersedeQuotes(conversation: PersistedConversation): void { - for (const message of conversation.messages) { - if (message.generationQuote?.status === 'active') { - message.generationQuote.status = 'superseded'; - } - } - } - - private bootstrapView(state: PersistedImageWorkspace): DesignWorkspaceBootstrap { - return { - capabilities: clone(LOCAL_CAPABILITIES), - workspaces: state.workspaces.map(({ clientWorkspaceId: _clientId, ...summary }) => ( - clone(summary) - )), - }; - } - - private workspaceView( - state: PersistedImageWorkspace, - workspace: PersistedWorkspace, - ): DesignWorkspace { - const { clientWorkspaceId: _clientId, ...summary } = workspace; - return clone({ - ...summary, - conversationCount: state.conversationsByWorkspaceId[workspace.workspaceId]?.length ?? 0, - conversations: (state.conversationsByWorkspaceId[workspace.workspaceId] ?? []) - .map((conversation) => this.conversationSummaryView(conversation)), - }); - } - - private conversationSummaryView( - conversation: PersistedConversation, - ): DesignConversationSummary { - const { - clientConversationId: _clientId, - requestHashes: _requestHashes, - messages: _messages, - ...summary - } = conversation; - return clone({ - ...summary, - latestMessagePreview: latestMessagePreview(conversation.messages), - }); - } - - private conversationView( - conversation: PersistedConversation, - before?: string, - ): DesignConversation { - const { - clientConversationId: _clientId, - requestHashes: _requestHashes, - ...view - } = conversation; - const endIndex = before === undefined - ? conversation.messages.length - : conversation.messages.findIndex((message) => message.id === before); - if (endIndex < 0) { - throw new LocalImageWorkspaceError( - 422, - 'conversation_history_cursor_invalid', - '历史会话分页位置已失效,请重新打开会话', - ); - } - const startIndex = Math.max(0, endIndex - LOCAL_CONVERSATION_MESSAGE_PAGE_SIZE); - const messages = conversation.messages.slice(startIndex, endIndex); - return clone({ - ...view, - messages, - messagePage: { - hasOlder: startIndex > 0, - nextBefore: startIndex > 0 ? messages[0]?.id ?? null : null, - }, - latestMessagePreview: latestMessagePreview(conversation.messages), - }); - } -} diff --git a/electron/image-workspace/module.ts b/electron/image-workspace/module.ts index 1e11a04..27bca95 100644 --- a/electron/image-workspace/module.ts +++ b/electron/image-workspace/module.ts @@ -1,33 +1,17 @@ import type { DesignAsset, DesignAssetUploadInput, - DesignCapabilities, - DesignConversation, - DesignCreateConversationInput, - DesignConfirmGenerationInput, + DesignCommandInput, + DesignCommandResult, DesignCreateWorkspaceInput, DesignDeleteWorkspaceResult, - DesignGenerationQuote, - DesignGenerationQuoteUpdateInput, - DesignGenerationTask, DesignRenameWorkspaceInput, - DesignSubmitMessageInput, DesignWorkspace, DesignWorkspaceBootstrap, - DesignWorkspaceEvent, + DesignWorkspaceEventSubscription, + DesignWorkspaceEventSubscriptionInput, } from '../../shared/image-workspace'; -export type DesignWorkspaceEventSubscription = { - events: AsyncIterable; - close(): void; -}; - -export type DesignWorkspaceEventSubscriptionInput = { - workspaceId: string; - conversationId: string; - afterEventId?: string; -}; - export type CloseEventSessionsOptions = { accessToken?: string; tolerateRemoteFailure?: boolean; @@ -45,28 +29,18 @@ export class DesignWorkspaceModuleError extends Error { } } +/** Main-owned deep boundary for one canonical V2 Living Form workspace. */ export interface DesignWorkspaceModule { bootstrap(): Promise; - getCapabilities(): Promise; createWorkspace(input: DesignCreateWorkspaceInput): Promise; deleteWorkspace(workspaceId: string): Promise; renameWorkspace(input: DesignRenameWorkspaceInput): Promise; getWorkspace(workspaceId: string): Promise; - createConversation(input: DesignCreateConversationInput): Promise; - getConversation( - workspaceId: string, - conversationId: string, - before?: string, - ): Promise; - submitMessage(input: DesignSubmitMessageInput): Promise; - updateGenerationQuote(input: DesignGenerationQuoteUpdateInput): Promise; - confirmGeneration(input: DesignConfirmGenerationInput): Promise; - listTasks(workspaceId: string): Promise; - uploadAsset?(input: DesignAssetUploadInput): Promise; - openWorkspaceEvents?( + submitCommand(input: DesignCommandInput): Promise; + uploadAsset(input: DesignAssetUploadInput): Promise; + openWorkspaceEvents( input: DesignWorkspaceEventSubscriptionInput, ): Promise; - closeEventSessions?(options?: CloseEventSessionsOptions): Promise; + closeEventSessions(options?: CloseEventSessionsOptions): 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 index f4ad5cf..4c097ac 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -1,175 +1,50 @@ -import type { - DesignAsset, - DesignAssetUploadInput, - DesignAssistantDeltaEvent, - DesignBrief, - DesignCapabilities, - DesignConversation, - DesignConversationSnapshotEvent, - DesignConversationSummary, - DesignConfirmGenerationInput, - DesignCreateConversationInput, - DesignCreateWorkspaceInput, - DesignDeleteWorkspaceResult, - DesignGenerationOption, - DesignGenerationQuoteUpdateInput, - DesignGenerationQuote, - DesignGenerationTask, - DesignGenerationTaskUpdatedEvent, - DesignMessage, - DesignRenameWorkspaceInput, - DesignSubmitMessageInput, - DesignWorkspace, - DesignWorkspaceBootstrap, - DesignWorkspaceEvent, - DesignWorkspaceSummary, -} from '../../shared/image-workspace'; import WebSocket from 'ws'; -import { designAssetContentPath } from '../../shared/image-workspace'; +import { + designAssetContentPath, + type DesignAsset, + type DesignAssetUploadInput, + type DesignAssistantDeltaEvent, + type DesignCapabilities, + type DesignChangeSet, + type DesignCommandInput, + type DesignCommandResult, + type DesignCompilationIssue, + type DesignCreateWorkspaceInput, + type DesignDecisionPrompt, + type DesignDeleteWorkspaceResult, + type DesignDirectionUpdatedEvent, + type DesignForm, + type DesignGenerationQuote, + type DesignGenerationTask, + type DesignInteractionOutcome, + type DesignQuoteBlockedEvent, + type DesignQuoteOutputSummary, + type DesignRenameWorkspaceInput, + type DesignSessionSnapshotEvent, + type DesignSpecification, + type DesignTurn, + type DesignUserInput, + type DesignWorkspace, + type DesignWorkspaceBootstrap, + type DesignWorkspaceEvent, + type DesignWorkspaceEventSubscription, + type DesignWorkspaceEventSubscriptionInput, + type DesignWorkspaceSummary, + type DesignWorkspaceUpdatedEvent, +} from '../../shared/image-workspace'; import { WORKS_SQUARE_CONFIG } from '../api/works-config'; +import { getValidWorksSquareAccessToken } from '../services/works-square-session'; import { fetchWithDeadline, proxyAwareFetch, RequestDeadlineExceededError, runWithDeadline, } from '../utils/proxy-fetch'; -import { getValidWorksSquareAccessToken } from '../services/works-square-session'; import { DesignWorkspaceModuleError, - type DesignWorkspaceEventSubscription, - type DesignWorkspaceEventSubscriptionInput, type DesignWorkspaceModule, } from './module'; -type ServerBrief = { - version: number; - status: DesignBrief['status']; - medium?: DesignBrief['medium']; - summary: string; - ready: boolean; - missing_decision: string | null; -}; - -type ServerGenerationOption = { - value?: string | number; - id?: string | number; - seconds?: number; - label?: string; - default?: boolean; - disabled?: boolean; - enabled?: boolean; - multiplier?: number; -}; - -type ServerQuote = { - quote_id: string; - status: DesignGenerationQuote['status']; - medium: DesignGenerationQuote['medium']; - brief_version: number; - brief_summary: string; - final_prompt: string; - prompt_mode: string; - generation_parameters: { - model: string; - resolution: string; - aspect_ratio: string; - duration_seconds: number | null; - }; - generation_options: { - models: ServerGenerationOption[]; - resolutions: ServerGenerationOption[]; - durations: ServerGenerationOption[]; - aspect_ratios: ServerGenerationOption[]; - }; - pricing: { - schema: string; - amount: number; - rounding: string; - }; - quoted_design_points: number; - expires_at: string; -}; - -type ServerMessage = { - message_id?: string; - 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; - view_revision: number; - conversation_count?: number; - phase: DesignWorkspaceSummary['phase']; - updated_at: string; -}; - -type ServerConversationSummary = { - conversation_id: string; - workspace_id: string; - agent_session_id: string | null; - title: string; - latest_message_preview?: string | null; - turn_revision: number; - phase: DesignConversationSummary['phase']; - brief: ServerBrief; - created_at: string; - updated_at: string; -}; - -type ServerConversation = ServerConversationSummary & { - messages: ServerMessage[]; - message_page?: { - has_older: boolean; - next_before: string | null; - }; -}; - -type ServerWorkspace = ServerWorkspaceSummary & { - conversations?: ServerConversationSummary[]; -}; - -type ServerWorkspaceCreation = ServerWorkspace & { - initial_conversation?: ServerConversation; -}; - -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; - conversation_id?: string | null; - 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; @@ -177,57 +52,22 @@ type WorksSquareDesignWorkspaceOptions = { requestTimeoutMs?: number; }; -type ServerAgentStreamTicket = { - stream_url: string; -}; - -type ServerAgentEvent = { - session_id: string; - sequence: number; - runtime: string; - type: string; - run_id?: unknown; - schema_version: number; - payload: unknown; -}; - -type ServerAgentCommand = { - run_id: string; - status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; - error: ServerAgentCommandError | null; -}; - -type ServerAgentCommandError = { - code: string; - message: string; - retryable: boolean; -}; - +type ServerErrorDetail = { code?: unknown; message?: unknown }; +type ServerAgentCommandError = { code: string; message: string; retryable: boolean }; +type ServerAgentCommand = { run_id: string; status: string; error: ServerAgentCommandError | null }; type ServerAgentRun = { run_id: string; status: 'queued' | 'running' | 'cancel_requested' | 'succeeded' | 'failed' | 'cancelled'; error: ServerAgentCommandError | null; }; - -type AgentDesignTurnSubmission = { - workspaceId: string; - conversationId: string; - agentSessionId: string; - clientTurnId: string; - expectedTurnRevision: number; - message: string; - attachmentAssetIds: string[]; - action: null | { - type: 'confirm_generation'; - quote_id: string; - final_prompt?: string; - aspect_ratio?: string; - generation_parameters?: { - model: string; - resolution: string; - duration_seconds: number | null; - }; - }; +type ServerAgentStreamTicket = { stream_url: string }; +type ServerAgentEvent = { + session_id: string; + sequence: number; + runtime: string; + type: string; + schema_version: number; + payload: unknown; }; type AgentWebSocket = { @@ -239,550 +79,527 @@ type AgentWebSocket = { send(data: string): void; close(code?: number, reason?: string): void; }; - -type AgentWebSocketConnection = { - socket: AgentWebSocket; - dispose?: () => void | Promise; -}; - +type AgentWebSocketConnection = { socket: AgentWebSocket; dispose?: () => void | Promise }; type AgentWebSocketFactory = ( url: string, ) => AgentWebSocketConnection | Promise; -type TaskEventQueue = { - events: AsyncIterable; - push(event: DesignWorkspaceEvent): Promise; - finish(): void; - fail(error: unknown): void; -}; +const REQUEST_TIMEOUT_MS = 30_000; +const RUN_TIMEOUT_MS = 10 * 60_000; +const RUN_INITIAL_POLL_MS = 500; +const RUN_MAX_POLL_MS = 4_000; +const WEBSOCKET_OPEN_TIMEOUT_MS = 10_000; +const WEBSOCKET_PING_INTERVAL_MS = 20_000; +const WEBSOCKET_OPEN = 1; -type QueuedTaskEvent = { - event: DesignWorkspaceEvent; - acknowledge(): void; -}; - -type AgentRunEventWaiter = (run: ServerAgentRun) => void; - -type AgentCommandWaiter = { - resolve(command: ServerAgentCommand): void; - reject(error: unknown): void; - timeout: ReturnType; -}; - -type AgentCommandChannel = { - socket: AgentWebSocket; - waiters: Map; -}; - -const AGENT_WEBSOCKET_OPEN = 1; -const DESIGN_WORKSPACE_REQUEST_TIMEOUT_MS = 30_000; -const AGENT_WEBSOCKET_OPEN_TIMEOUT_MS = 10_000; -const AGENT_WEBSOCKET_PING_INTERVAL_MS = 20_000; -const AGENT_COMMAND_ACK_TIMEOUT_MS = 5_000; -const AGENT_RUN_INITIAL_POLL_INTERVAL_MS = 1_000; -const AGENT_RUN_MAX_POLL_INTERVAL_MS = 5_000; -const AGENT_RUN_TIMEOUT_MS = 10 * 60_000; -const DESIGN_EVENT_DELIVERY_BARRIER_TIMEOUT_MS = 1_000; - -class AgentCommandTransportError extends Error {} - -function mapBrief(brief: ServerBrief): DesignBrief { - const medium = brief.medium ?? null; - if (medium !== null && medium !== 'image' && medium !== 'video') { +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new DesignWorkspaceModuleError( 502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', - 'AI 设计服务返回了无效的媒介类型', + 'AI 设计服务返回了无效数据', ); } - return { - version: brief.version, - status: brief.status, - medium, - summary: brief.summary, - ready: brief.ready, - missingDecision: brief.missing_decision, - }; + return value as Record; } -function mapGenerationOptions( - options: ServerGenerationOption[] | undefined, -): DesignGenerationOption[] { - return (options ?? []).flatMap((option) => { - const value = option.value ?? option.id ?? option.seconds; - if (typeof value !== 'string' && typeof value !== 'number') return []; - return [{ - value: value as TValue, - label: option.label ?? String(value), - default: option.default ?? false, - disabled: option.disabled ?? option.enabled === false, - multiplier: option.multiplier ?? 1, - }]; - }); -} - -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, - finalPrompt: quote.final_prompt, - promptMode: quote.prompt_mode, - generationParameters: { - model: quote.generation_parameters.model, - resolution: quote.generation_parameters.resolution, - aspectRatio: quote.generation_parameters.aspect_ratio, - durationSeconds: quote.generation_parameters.duration_seconds, - }, - generationOptions: { - models: mapGenerationOptions(quote.generation_options.models), - resolutions: mapGenerationOptions(quote.generation_options.resolutions), - durations: mapGenerationOptions(quote.generation_options.durations), - aspectRatios: mapGenerationOptions(quote.generation_options.aspect_ratios), - }, - pricing: { - schema: quote.pricing.schema, - amount: quote.pricing.amount, - rounding: quote.pricing.rounding, - }, - quotedDesignPoints: quote.quoted_design_points, - expiresAt: quote.expires_at, - }; -} - -function mapWorkspaceSummary(workspace: ServerWorkspaceSummary): DesignWorkspaceSummary { - return { - workspaceId: workspace.workspace_id, - title: workspace.title, - viewRevision: workspace.view_revision, - conversationCount: workspace.conversation_count ?? 0, - phase: workspace.phase, - updatedAt: workspace.updated_at, - }; -} - -function mapWorkspace(workspace: ServerWorkspace): DesignWorkspace { - const conversations = workspace.conversations ?? []; - return { - ...mapWorkspaceSummary(workspace), - conversationCount: workspace.conversation_count ?? conversations.length, - conversations: conversations.map(mapConversationSummary), - }; -} - -function normalizeMessagePreview(value: string | null | undefined): string | null { - const preview = value?.replace(/\s+/gu, ' ').trim(); - return preview || null; -} - -function latestServerMessagePreview(messages: ServerMessage[]): string | null { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const preview = normalizeMessagePreview(messages[index]?.text); - if (preview) return preview; +function stringValue(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', `${field} 无效`); } - return null; + return value; } -function mapConversationSummary( - conversation: ServerConversationSummary, -): DesignConversationSummary { +function numberValue(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', `${field} 无效`); + } + return value; +} + +function arrayValue(value: unknown, field: string): unknown[] { + if (!Array.isArray(value)) { + throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', `${field} 无效`); + } + return value; +} + +function nullableString(value: unknown): string | null { + return value === null || value === undefined ? null : stringValue(value, '可选文本'); +} + +function mapWorkspaceSummary(value: unknown): DesignWorkspaceSummary { + const item = record(value); return { - conversationId: conversation.conversation_id, - workspaceId: conversation.workspace_id, - title: conversation.title, - latestMessagePreview: normalizeMessagePreview( - conversation.latest_message_preview, - ) ?? normalizeMessagePreview(conversation.brief.summary), - turnRevision: conversation.turn_revision, - phase: conversation.phase, - brief: mapBrief(conversation.brief), - createdAt: conversation.created_at, - updatedAt: conversation.updated_at, + workspaceId: stringValue(item.workspace_id, 'Workspace ID'), + clientWorkspaceId: stringValue(item.client_workspace_id, 'Client Workspace ID'), + title: stringValue(item.title, 'Workspace title'), + directionId: stringValue(item.direction_id, 'Direction ID'), + sessionId: stringValue(item.session_id, 'Session ID'), + directionRevision: numberValue(item.direction_revision, 'Direction revision'), + specificationRevision: numberValue(item.specification_revision, 'Specification revision'), + workspaceViewRevision: numberValue(item.workspace_view_revision, 'Workspace revision'), + updatedAt: stringValue(item.updated_at, 'Workspace updated time'), }; } -function mapConversation(conversation: ServerConversation): DesignConversation { - const messages = conversation.messages.map((message, index) => ({ - id: message.message_id - ?? `${conversation.conversation_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 mapIssue(value: unknown): DesignCompilationIssue { + const issue = record(value); + const severity = issue.severity === 'blocker' ? 'blocker' : 'warning'; + const sourcePaths = Array.isArray(issue.source_paths) + ? issue.source_paths.filter((path): path is string => typeof path === 'string') + : []; return { - ...mapConversationSummary(conversation), - latestMessagePreview: latestServerMessagePreview(messages) - ?? normalizeMessagePreview(conversation.latest_message_preview) - ?? normalizeMessagePreview(conversation.brief.summary), - messages, - messagePage: conversation.message_page - ? { - hasOlder: conversation.message_page.has_older, - nextBefore: conversation.message_page.next_before, - } - : undefined, + code: stringValue(issue.code, 'Issue code'), + message: stringValue(issue.message, 'Issue message'), + severity, + path: sourcePaths[0] ?? null, }; } -function mapTask(task: ServerTask): DesignGenerationTask { +function mapOutputSummary(value: unknown): DesignQuoteOutputSummary { + const summary = record(value); + const medium = summary.medium; + if (medium !== 'image' && medium !== 'video') { + throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', '设计媒介无效'); + } return { - taskId: task.task_id, - workspaceId: task.workspace_id, - conversationId: task.conversation_id ?? null, - 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) => mapAsset(task.workspace_id, asset)), - createdAt: task.created_at, - updatedAt: task.updated_at, + medium, + strategy: summary.strategy === 'multi_step' ? 'multi_step' : 'direct', + aspectRatio: stringValue(summary.aspect_ratio, 'Aspect ratio'), + outputCount: numberValue(summary.output_count, 'Output count'), + deliveryFormat: nullableString(summary.delivery_format), + durationSeconds: summary.duration_seconds === null + ? null + : numberValue(summary.duration_seconds, 'Duration'), + requiredOutputRoles: arrayValue(summary.required_output_roles, 'Output roles') + .map((role) => stringValue(role, 'Output role')), }; } -function mapAsset(workspaceId: string, asset: ServerAsset): DesignAsset { +function mapQuote(value: unknown): DesignGenerationQuote { + const quote = record(value); + const medium = quote.medium; + if (medium !== 'image' && medium !== 'video') { + throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', '报价媒介无效'); + } + const status = quote.status; + if (!['offered', 'consumed', 'expired', 'superseded'].includes(String(status))) { + throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', '报价状态无效'); + } return { - assetId: asset.asset_id, + quoteId: stringValue(quote.quote_id, 'Quote ID'), + status: status as DesignGenerationQuote['status'], + specificationRevision: numberValue(quote.specification_revision, 'Quote revision'), + specificationRevisionId: stringValue(quote.specification_revision_id, 'Quote spec ID'), + specificationDigest: stringValue(quote.specification_digest, 'Quote digest'), + medium, + compilerVersion: stringValue(quote.compiler_version, 'Compiler version'), + outputSummary: mapOutputSummary(quote.output_summary), + warnings: arrayValue(quote.warnings, 'Quote warnings').map(mapIssue), + quotedDesignPoints: numberValue(quote.quoted_design_points, 'Quoted points'), + maximumCustomerChargeAtoms: numberValue( + quote.maximum_customer_charge_atoms, + 'Maximum charge', + ), + expiresAt: stringValue(quote.expires_at, 'Quote expiry'), + }; +} + +function mapChangeSet(value: unknown): DesignChangeSet { + const changeSet = record(value); + return { + interaction_id: stringValue(changeSet.interaction_id, 'Interaction ID'), + changes: arrayValue(changeSet.changes, 'Changes') as DesignChangeSet['changes'], + }; +} + +function mapDecisionPrompt(value: unknown): DesignDecisionPrompt { + const prompt = record(value); + const content = record(prompt.content); + return { + id: stringValue(prompt.id, 'Prompt ID'), + kind: prompt.kind === 'question' ? 'question' : 'suggestion', + basedOnSpecificationRevisionId: stringValue( + prompt.based_on_specification_revision_id, + 'Prompt revision', + ), + targetPaths: arrayValue(prompt.target_paths, 'Prompt paths') + .map((path) => stringValue(path, 'Prompt path')), + title: stringValue(content.title, 'Prompt title'), + body: stringValue(content.body, 'Prompt body'), + options: arrayValue(content.options, 'Prompt options').map((rawOption) => { + const option = record(rawOption); + return { + id: stringValue(option.id, 'Option ID'), + label: stringValue(option.label, 'Option label'), + description: nullableString(option.description), + }; + }), + }; +} + +function mapForm(value: unknown): DesignForm { + const form = record(value); + const specification = record(form.specification); + return { + schemaVersion: 1, + workspaceId: stringValue(form.workspace_id, 'Workspace ID'), + directionId: stringValue(form.direction_id, 'Direction ID'), + directionRevision: numberValue(form.direction_revision, 'Direction revision'), + rawTurnSequence: numberValue(form.raw_turn_sequence, 'Turn sequence'), + specificationRevision: numberValue(form.specification_revision, 'Specification revision'), + workspaceViewRevision: numberValue(form.workspace_view_revision, 'Workspace revision'), + specificationRevisionId: stringValue(form.specification_revision_id, 'Specification ID'), + specificationDigest: stringValue(form.specification_digest, 'Specification digest'), + specification: specification as unknown as DesignSpecification, + decisionPrompts: arrayValue(form.decision_prompts, 'Decision prompts').map(mapDecisionPrompt), + activeQuotes: arrayValue(form.active_quotes, 'Active quotes').map(mapQuote), + recentChangeSet: mapChangeSet(form.recent_change_set), + }; +} + +function mapTurn(value: unknown): DesignTurn { + const turn = record(value); + return { + turnId: stringValue(turn.turn_id, 'Turn ID'), + rawTurnSequence: numberValue(turn.raw_turn_sequence, 'Turn sequence'), + userMessage: stringValue(turn.user_message, 'User message'), + assistantMessage: stringValue(turn.assistant_message, 'Assistant message'), + }; +} + +function mapAsset(workspaceId: string, value: unknown): DesignAsset { + const asset = record(value); + const mediaType = asset.media_type; + if (mediaType !== 'image' && mediaType !== 'video') { + throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', '素材类型无效'); + } + const role = asset.role === 'generated' ? 'generated' : 'uploaded'; + return { + assetId: stringValue(asset.asset_id, 'Asset ID'), workspaceId, - mediaType: asset.media_type, - mimeType: asset.mime_type, - width: asset.width, - height: asset.height, - durationMilliseconds: asset.duration_milliseconds, - createdAt: asset.created_at, - contentPath: designAssetContentPath(workspaceId, asset.asset_id), + role, + mediaType, + mimeType: stringValue(asset.mime_type, 'Asset MIME'), + width: numberValue(asset.width, 'Asset width'), + height: numberValue(asset.height, 'Asset height'), + durationMilliseconds: asset.duration_milliseconds === null + ? null + : numberValue(asset.duration_milliseconds, 'Asset duration'), + generationTaskId: nullableString(asset.generation_task_id), + createdAt: stringValue(asset.created_at, 'Asset creation time'), + contentPath: designAssetContentPath(workspaceId, stringValue(asset.asset_id, 'Asset ID')), }; } -function isServerTask(value: unknown): value is ServerTask { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const task = value as Record; - return typeof task.task_id === 'string' - && typeof task.workspace_id === 'string' - && (task.conversation_id === undefined - || task.conversation_id === null - || typeof task.conversation_id === 'string') - && (task.medium === 'image' || task.medium === 'video') - && ['queued', 'running', 'succeeded', 'failed', 'cancelled'].includes(String(task.status)) - && typeof task.brief_version === 'number' - && typeof task.brief_summary === 'string' - && Array.isArray(task.result_assets) - && typeof task.created_at === 'string' - && typeof task.updated_at === 'string'; +function mapTask(value: unknown): DesignGenerationTask { + const task = record(value); + const medium = task.medium; + if (medium !== 'image' && medium !== 'video') { + throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', '任务媒介无效'); + } + const billing = record(task.customer_billing); + const progress = record(task.progress); + const cancellation = record(task.cancellation); + return { + taskId: stringValue(task.task_id, 'Task ID'), + quoteId: stringValue(task.quote_id, 'Quote ID'), + status: task.status as DesignGenerationTask['status'], + taskRevision: numberValue(task.task_revision, 'Task revision'), + specificationRevision: numberValue(task.specification_revision, 'Task spec revision'), + specificationRevisionId: stringValue(task.specification_revision_id, 'Task spec ID'), + specificationDigest: stringValue(task.specification_digest, 'Task digest'), + medium, + compilerVersion: stringValue(task.compiler_version, 'Task compiler'), + outputSummary: mapOutputSummary(task.output_summary), + maximumCustomerChargeAtoms: numberValue(task.maximum_customer_charge_atoms, 'Task charge'), + customerBilling: { + quotedAtoms: numberValue(billing.quoted_atoms, 'Quoted atoms'), + heldAtoms: numberValue(billing.held_atoms, 'Held atoms'), + chargedAtoms: numberValue(billing.charged_atoms, 'Charged atoms'), + refundedAtoms: numberValue(billing.refunded_atoms, 'Refunded atoms'), + pendingResolutionAtoms: numberValue( + billing.pending_resolution_atoms, + 'Pending resolution atoms', + ), + }, + customerHoldExpiresAt: stringValue(task.customer_hold_expires_at, 'Hold expiry'), + resolutionDeadlineAt: nullableString(task.resolution_deadline_at), + progress: { + stage: progress.stage as DesignGenerationTask['progress']['stage'], + completedRequiredSteps: numberValue(progress.completed_required_steps, 'Completed steps'), + totalRequiredSteps: numberValue(progress.total_required_steps, 'Required steps'), + }, + executionHealth: task.execution_health as DesignGenerationTask['executionHealth'], + cancellation: { + state: cancellation.state as DesignGenerationTask['cancellation']['state'], + outcome: cancellation.outcome as DesignGenerationTask['cancellation']['outcome'], + }, + issues: arrayValue(task.issues, 'Task issues').map((rawIssue) => { + const issue = record(rawIssue); + return { + code: stringValue(issue.code, 'Task issue code'), + message: stringValue(issue.message, 'Task issue message'), + recoveryOwner: issue.recovery_owner as 'platform' | 'operator', + }; + }), + resultAssetIds: arrayValue(task.result_asset_ids, 'Task assets') + .map((id) => stringValue(id, 'Task asset ID')), + nextActions: arrayValue(task.next_actions, 'Task actions') as DesignGenerationTask['nextActions'], + createdAt: stringValue(task.created_at, 'Task creation time'), + }; } -function isServerConversation(value: unknown): value is ServerConversation { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const conversation = value as Record; - const brief = conversation.brief as Record | null; - return typeof conversation.conversation_id === 'string' - && typeof conversation.workspace_id === 'string' - && (conversation.agent_session_id === null - || typeof conversation.agent_session_id === 'string') - && typeof conversation.title === 'string' - && Number.isInteger(conversation.turn_revision) - && Number(conversation.turn_revision) >= 0 - && ['shaping', 'awaiting_confirmation', 'blocked'].includes(String(conversation.phase)) - && Boolean(brief) - && Number.isInteger(brief?.version) - && ['draft', 'ready', 'confirmed'].includes(String(brief?.status)) - && (brief?.medium === undefined - || brief.medium === null - || brief.medium === 'image' - || brief.medium === 'video') - && typeof brief?.summary === 'string' - && typeof brief?.ready === 'boolean' - && (brief?.missing_decision === null || typeof brief?.missing_decision === 'string') - && Array.isArray(conversation.messages) - && conversation.messages.every((message) => { - if (!message || typeof message !== 'object' || Array.isArray(message)) return false; - const item = message as Record; - return (item.role === 'user' || item.role === 'assistant') - && ['user', 'reply', 'choice', 'confirmation', 'safety_redirect', 'failure'] - .includes(String(item.kind)) - && typeof item.text === 'string' - && Array.isArray(item.quick_replies) - && item.quick_replies.every((reply) => typeof reply === 'string') - && Number.isInteger(item.turn_revision) - && Number(item.turn_revision) >= 0 - && typeof item.created_at === 'string'; - }) - && typeof conversation.created_at === 'string' - && typeof conversation.updated_at === 'string'; +function mapWorkspace(value: unknown): DesignWorkspace { + const detail = record(value); + const summary = mapWorkspaceSummary(detail.workspace); + return { + workspace: summary, + form: mapForm(detail.form), + turns: arrayValue(detail.turns, 'Turns').map(mapTurn), + tasks: arrayValue(detail.tasks, 'Tasks').map(mapTask), + assets: arrayValue(detail.assets, 'Assets').map((asset) => mapAsset(summary.workspaceId, asset)), + }; +} + +function mapInteraction(value: unknown): DesignInteractionOutcome { + const operation = record(value); + return { + operationKind: operation.operation_kind as DesignInteractionOutcome['operationKind'], + interactionId: stringValue(operation.interaction_id, 'Interaction ID'), + baseDirectionRevision: numberValue(operation.base_direction_revision, 'Base revision'), + newDirectionRevision: numberValue(operation.new_direction_revision, 'New revision'), + rawTurnSequence: numberValue(operation.raw_turn_sequence, 'Turn sequence'), + specificationRevision: numberValue(operation.specification_revision, 'Spec revision'), + specificationRevisionId: stringValue(operation.specification_revision_id, 'Spec ID'), + workspaceViewRevision: numberValue(operation.workspace_view_revision, 'Workspace revision'), + specificationRevisionCreated: Boolean(operation.specification_revision_created), + meaningChanged: Boolean(operation.meaning_changed), + changeSet: mapChangeSet(operation.change_set), + turnId: nullableString(operation.turn_id), + assistantMessage: nullableString(operation.assistant_message), + createdDecisionPromptIds: arrayValue( + operation.created_decision_prompt_ids ?? [], + 'Created prompt IDs', + ).map((id) => stringValue(id, 'Prompt ID')), + resolvedDecisionPromptId: nullableString(operation.resolved_decision_prompt_id), + supersededDecisionPromptIds: arrayValue( + operation.superseded_decision_prompt_ids ?? [], + 'Superseded prompt IDs', + ).map((id) => stringValue(id, 'Prompt ID')), + supersededQuoteCount: numberValue(operation.superseded_quote_count, 'Superseded quotes'), + quoteId: nullableString(operation.quote_id), + generationTaskId: nullableString(operation.generation_task_id), + }; +} + +function agentEventFromFrame(data: unknown): unknown | null { + if (typeof data !== 'string') return null; + try { + const frame = JSON.parse(data) as Record; + return frame?.type === 'event' ? frame.event ?? null : null; + } catch { + return null; + } } function normalizeWorkspaceEvent( value: unknown, sessionId: string, workspaceId: string, - conversationId: string, ): DesignWorkspaceEvent | null { try { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - const event = value as ServerAgentEvent; + const event = record(value) as unknown as ServerAgentEvent; if (event.session_id !== sessionId - || !Number.isInteger(event.sequence) - || event.sequence < 1 || event.runtime !== 'design' || event.schema_version !== 1 - || !event.payload - || typeof event.payload !== 'object' - || Array.isArray(event.payload)) { - return null; + || !Number.isInteger(event.sequence) + || event.sequence < 1) return null; + const payload = record(event.payload); + const id = `${sessionId}:${event.sequence}`; + if (event.type === 'design.session.snapshot') { + return { id, type: event.type, form: mapForm(payload.form) } satisfies DesignSessionSnapshotEvent; } - const payload = event.payload as Record; if (event.type === 'design.assistant.delta') { - if (payload.workspace_id !== workspaceId - || payload.conversation_id !== conversationId - || typeof payload.client_turn_id !== 'string' - || payload.client_turn_id.length < 1 - || payload.client_turn_id.length > 128 - || !Number.isInteger(payload.turn_revision) - || Number(payload.turn_revision) < 1 - || !Number.isInteger(payload.chunk_index) - || Number(payload.chunk_index) < 0 - || typeof payload.delta !== 'string' - || payload.delta.length < 1 - || payload.delta.length > 128) { - return null; - } + if (payload.workspace_id !== workspaceId) return null; return { - id: `${sessionId}:${event.sequence}`, - type: 'design.assistant.delta', + id, + type: event.type, workspaceId, - conversationId, - clientTurnId: payload.client_turn_id, - turnRevision: Number(payload.turn_revision), - chunkIndex: Number(payload.chunk_index), - delta: payload.delta, + directionId: stringValue(payload.direction_id, 'Direction ID'), + clientOperationId: stringValue(payload.client_operation_id, 'Operation ID'), + directionRevision: numberValue(payload.direction_revision, 'Direction revision'), + chunkIndex: numberValue(payload.chunk_index, 'Chunk index'), + delta: stringValue(payload.delta, 'Assistant delta'), } satisfies DesignAssistantDeltaEvent; } - - if (event.type === 'design.generation_task.updated') { - if (payload.workspace_id !== workspaceId - || !Number.isInteger(payload.workspace_view_revision) - || Number(payload.workspace_view_revision) < 1 - || !isServerTask(payload.generation_task) - || payload.generation_task.workspace_id !== workspaceId) { - return null; - } + if (event.type === 'design.direction.updated') { + const form = mapForm(payload.form); + if (form.workspaceId !== workspaceId) return null; return { - id: `${sessionId}:${event.sequence}`, - type: 'design.generation_task.updated', - workspaceId, - workspaceViewRevision: Number(payload.workspace_view_revision), - generationTask: mapTask(payload.generation_task), - } satisfies DesignGenerationTaskUpdatedEvent; + id, + type: event.type, + replayed: Boolean(payload.replayed), + operation: mapInteraction(payload.operation), + form, + } satisfies DesignDirectionUpdatedEvent; } - - if ((event.type !== 'design.conversation.updated' - && event.type !== 'design.workspace.updated') - || payload.workspace_id !== workspaceId - || payload.conversation_id !== conversationId - || !isServerConversation(payload.conversation) - || !Array.isArray(payload.generation_tasks)) { - return null; + if (event.type === 'design.quote.blocked') { + const form = mapForm(payload.form); + if (form.workspaceId !== workspaceId) return null; + return { + id, + type: event.type, + clientOperationId: stringValue(payload.client_operation_id, 'Operation ID'), + blockers: arrayValue(payload.blockers, 'Quote blockers').map(mapIssue), + warnings: arrayValue(payload.warnings, 'Quote warnings').map(mapIssue), + form, + } satisfies DesignQuoteBlockedEvent; } - const conversation = payload.conversation; - if (conversation.workspace_id !== workspaceId - || conversation.conversation_id !== conversationId - || !Number.isInteger(payload.workspace_view_revision) - || Number(payload.workspace_view_revision) < 0 - || !payload.generation_tasks.every( - (task) => isServerTask(task) && task.workspace_id === workspaceId, - )) { - return null; - } - return { - id: `${sessionId}:${event.sequence}`, - type: 'design.conversation.snapshot', - workspaceId, - conversationId, - workspaceViewRevision: Number(payload.workspace_view_revision), - conversation: mapConversation(conversation), - generationTasks: payload.generation_tasks.map((task) => mapTask(task as ServerTask)), - } satisfies DesignConversationSnapshotEvent; - } catch { - return null; - } -} - -function agentEventFromWebSocketFrame(data: unknown): unknown | null { - if (typeof data !== 'string') return null; - try { - const frame = JSON.parse(data) as Record; - return frame - && typeof frame === 'object' - && !Array.isArray(frame) - && frame.type === 'event' - ? frame.event ?? null - : null; - } catch { - return null; - } -} - -function normalizeAgentRunEvent(value: unknown, sessionId: string): ServerAgentRun | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - const event = value as ServerAgentEvent; - if (event.session_id !== sessionId - || !Number.isInteger(event.sequence) - || event.sequence < 1 - || event.runtime !== 'design' - || event.schema_version !== 1 - || typeof event.run_id !== 'string' - || event.run_id.length === 0) { - return null; - } - const status = event.type === 'run.completed' - ? 'succeeded' - : event.type === 'run.failed' - ? 'failed' - : event.type === 'run.cancelled' - ? 'cancelled' + if (event.type === 'design.workspace.updated') { + if (payload.workspace_id !== workspaceId) return null; + const changedDirection = payload.changed_direction + ? record(payload.changed_direction) : null; - if (!status) return null; - const payload = event.payload && typeof event.payload === 'object' && !Array.isArray(event.payload) - ? event.payload as Record - : null; - const rawError = payload?.error; - const error = rawError && typeof rawError === 'object' && !Array.isArray(rawError) - && typeof (rawError as Record).code === 'string' - && typeof (rawError as Record).message === 'string' - && typeof (rawError as Record).retryable === 'boolean' - ? rawError as ServerAgentCommandError - : null; - return { run_id: event.run_id, status, error }; + return { + id, + type: event.type, + workspaceId, + workspaceViewRevision: numberValue(payload.workspace_view_revision, 'Workspace revision'), + changedDirection: changedDirection + ? { + directionId: stringValue(changedDirection.direction_id, 'Direction ID'), + directionRevision: numberValue( + changedDirection.direction_revision, + 'Direction revision', + ), + specificationRevision: numberValue( + changedDirection.specification_revision, + 'Specification revision', + ), + } + : null, + changedTask: payload.changed_task ? mapTask(payload.changed_task) : null, + } satisfies DesignWorkspaceUpdatedEvent; + } + return null; + } catch { + return null; + } } -function createTaskEventQueue(): TaskEventQueue { - const queued: QueuedTaskEvent[] = []; - const pendingDeliveries = new Set(); - const waiters: Array<() => void> = []; - let finished = false; - let failed = false; - let failure: unknown; - const wake = () => { - for (const resolve of waiters.splice(0)) resolve(); +function mapUserInput(input: DesignUserInput): Record { + if (input.kind === 'direct_edit') return input; + if (input.kind === 'chat') return input; + if (input.kind === 'restore') { + return { kind: input.kind, specification_revision_id: input.specificationRevisionId }; + } + return { + kind: input.kind, + prompt_id: input.promptId, + action: input.action, + option_id: input.optionId ?? null, }; +} +function agentCommand(input: DesignCommandInput): Record { + if (input.kind === 'apply_input') { + return { + client_command_id: input.clientOperationId, + name: 'design.input.apply', + input: { + expected_direction_revision: input.expectedDirectionRevision, + client_operation_id: input.clientOperationId, + input: mapUserInput(input.input), + }, + }; + } + if (input.kind === 'request_quote') { + return { + client_command_id: input.clientOperationId, + name: 'design.quote.request', + input: { + expected_direction_revision: input.expectedDirectionRevision, + specification_revision: input.specificationRevision, + client_operation_id: input.clientOperationId, + }, + }; + } + return { + client_command_id: input.clientOperationId, + name: 'design.generation.confirm', + input: { + quote_id: input.quoteId, + expected_direction_revision: input.expectedDirectionRevision, + client_operation_id: input.clientOperationId, + }, + }; +} + +function createEventQueue(): { + events: AsyncIterable; + push(event: DesignWorkspaceEvent): void; + finish(): void; + fail(error: unknown): void; +} { + const queued: DesignWorkspaceEvent[] = []; + const waiters: Array<() => void> = []; + let ended = false; + let failure: unknown; + const wake = () => waiters.splice(0).forEach((resolve) => resolve()); return { events: { async *[Symbol.asyncIterator]() { while (true) { - const entry = queued.shift(); - if (entry) { - try { - yield entry.event; - } finally { - entry.acknowledge(); - } + const event = queued.shift(); + if (event) { + yield event; continue; } - if (failed) throw failure; - if (finished) return; + if (failure) throw failure; + if (ended) return; await new Promise((resolve) => waiters.push(resolve)); } }, }, push(event) { - if (finished || failed) return Promise.resolve(); - let resolveDelivery!: () => void; - const delivered = new Promise((resolve) => { - resolveDelivery = resolve; - }); - let acknowledged = false; - const entry: QueuedTaskEvent = { - event, - acknowledge() { - if (acknowledged) return; - acknowledged = true; - pendingDeliveries.delete(entry); - resolveDelivery(); - }, - }; - pendingDeliveries.add(entry); - queued.push(entry); + if (ended || failure) return; + queued.push(event); wake(); - return delivered; }, finish() { - if (finished || failed) return; - finished = true; - for (const entry of [...pendingDeliveries]) entry.acknowledge(); + ended = true; wake(); }, fail(error) { - if (finished || failed) return; - failed = true; failure = error; - for (const entry of [...pendingDeliveries]) entry.acknowledge(); wake(); }, }; } -function boundedTaskEventDelivery(delivered: Promise): Promise { - return new Promise((resolve) => { - const timeout = setTimeout(resolve, DESIGN_EVENT_DELIVERY_BARRIER_TIMEOUT_MS); - void delivered.then(() => { - clearTimeout(timeout); - resolve(); - }); - }); -} - -function webSocketCloseError(code: number): DesignWorkspaceModuleError | null { - if (code === 1000 || code === 1001) return null; - if (code === 4401) { - return new DesignWorkspaceModuleError( - 401, - 'DESIGN_EVENT_TICKET_INVALID', - 'AI 设计任务连接凭证已失效', - ); - } - if (code === 4404) { - return new DesignWorkspaceModuleError( - 404, - 'DESIGN_EVENT_SESSION_NOT_FOUND', - 'AI 设计任务状态会话不存在', - ); - } - if (code === 4409) { - return new DesignWorkspaceModuleError( - 410, - 'DESIGN_EVENT_CURSOR_EXPIRED', - 'AI 设计任务状态断点已过期', - ); - } - return new DesignWorkspaceModuleError( - 502, - 'DESIGN_EVENT_STREAM_UNAVAILABLE', - 'AI 设计任务状态连接已断开', - ); -} - -function defaultAgentWebSocketFactory(url: string): AgentWebSocketConnection { - return { - socket: new WebSocket(url) as unknown as AgentWebSocket, - }; -} - function eventSequence(afterEventId: string | undefined, sessionId: string): number { if (!afterEventId?.startsWith(`${sessionId}:`)) return 0; const sequence = Number(afterEventId.slice(sessionId.length + 1)); return Number.isSafeInteger(sequence) && sequence >= 0 ? sequence : 0; } +function defaultAgentWebSocketFactory(url: string): AgentWebSocketConnection { + return { socket: new WebSocket(url) as unknown as AgentWebSocket }; +} + +function webSocketCloseError(code: number): DesignWorkspaceModuleError | null { + if (code === 1000 || code === 1001) return null; + if (code === 4409) { + return new DesignWorkspaceModuleError(410, 'DESIGN_EVENT_CURSOR_EXPIRED', '设计状态断点已过期'); + } + if (code === 4404) { + return new DesignWorkspaceModuleError(404, 'DESIGN_EVENT_SESSION_NOT_FOUND', '设计会话不存在'); + } + return new DesignWorkspaceModuleError(502, 'DESIGN_EVENT_STREAM_UNAVAILABLE', '设计状态连接已断开'); +} + async function readPayload(response: Response): Promise { const text = await response.text(); if (!text.trim()) return null; @@ -796,24 +613,18 @@ async function readPayload(response: Response): Promise { 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; + return detail && typeof detail === 'object' && !Array.isArray(detail) + ? detail as ServerErrorDetail + : payload as ServerErrorDetail; } function userFacingErrorMessage(code: string): string { - if (code === 'generation_task_not_created') { - return '生成方案已确认,但任务创建失败,请刷新后重试'; - } 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 重新确认', + design_direction_revision_conflict: '设计表单已更新,正在重新同步', + design_idempotency_conflict: '这次操作与已经受理的请求冲突', + design_quote_blocked: '当前设计信息还不足以生成,请先补全表单', + design_quote_expired: '当前报价已过期,请重新获取报价', + design_quote_consumed: '当前报价已经生成过', budget_denied: '当前设计点不足,无法开始生成', policy_blocked: '当前内容不符合创作安全规则', design_reasoner_unavailable: '设计 Agent 暂时不可用,请稍后重试', @@ -821,42 +632,21 @@ function userFacingErrorMessage(code: string): string { design_production_unavailable: '当前生成能力暂时不可用', agent_runtime_unavailable: 'AI 设计服务暂时不可用,请稍后重试', agent_command_invalid: '设计请求内容无效,请检查后重试', - design_agent_run_cancelled: '设计 Agent 请求已取消', - design_agent_run_failed: '设计 Agent 暂时不可用,请稍后重试', }; return messages[code] ?? 'AI 设计请求失败,请稍后重试'; } function agentRunErrorStatus(code: string): number { - if (code === 'workspace_not_found') return 404; + if (code.includes('not_found')) return 404; if (code === 'budget_denied') return 402; - if (code === 'workspace_revision_conflict' - || code === 'idempotency_conflict' - || code === 'generation_quote_expired' - || code === 'generation_quote_consumed' - || code === 'generation_quote_invalid') { - return 409; - } - if (code === 'agent_command_invalid' - || code === 'reference_asset_invalid' - || code === 'policy_blocked') { + if (code.includes('conflict') || code.includes('expired') || code.includes('consumed')) return 409; + if (code.includes('invalid') || code === 'policy_blocked' || code === 'design_quote_blocked') { return 422; } - if (code === 'design_reasoner_unavailable' - || code === 'design_runtime_unavailable' - || code === 'design_production_unavailable' - || code === 'agent_runtime_unavailable') { - return 503; - } + if (code.includes('unavailable')) return 503; return 502; } -function isStaleAgentSessionError(error: unknown): error is DesignWorkspaceModuleError { - return error instanceof DesignWorkspaceModuleError - && (error.code === 'agent_session_not_found' - || error.code === 'agent_session_closed'); -} - function designRequestTimeoutError(): DesignWorkspaceModuleError { return new DesignWorkspaceModuleError( 504, @@ -870,49 +660,36 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { private readonly fetchImpl: typeof fetch; private readonly webSocketFactory: AgentWebSocketFactory; private readonly requestTimeoutMs: number; - private readonly conversationSessionIds = new Map(); private readonly eventSubscriptionClosers = new Map void>>(); - private readonly activeRunEventStreams = new Map(); - private readonly terminalAgentRuns = new Map(); - private readonly agentRunEventWaiters = new Map>(); - private readonly runStreamEndWaiters = new Map void>>(); - private readonly agentCommandChannels = new Map>(); - private nextAgentCommandRequestId = 0; private eventSessionsEnabled = true; constructor(options: WorksSquareDesignWorkspaceOptions = {}) { this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); - this.fetchImpl = options.fetchImpl ?? proxyAwareFetch; + this.fetchImpl = options.fetchImpl ?? (proxyAwareFetch as typeof fetch); this.webSocketFactory = options.webSocketFactory ?? defaultAgentWebSocketFactory; - this.requestTimeoutMs = options.requestTimeoutMs ?? DESIGN_WORKSPACE_REQUEST_TIMEOUT_MS; + this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS; } async bootstrap(): Promise { const [capabilities, workspaces] = await Promise.all([ - this.getCapabilities(), - this.requestJson('/api/design/workspaces?limit=100&offset=0'), + this.requestJson('/api/design/capabilities'), + this.requestJson('/api/design/workspaces?limit=100&offset=0'), ]); this.eventSessionsEnabled = true; - return { - capabilities, - workspaces: workspaces.map(mapWorkspaceSummary), - }; - } - - getCapabilities(): Promise { - return this.requestJson('/api/design/capabilities'); + return { capabilities, workspaces: workspaces.map(mapWorkspaceSummary) }; } async createWorkspace(input: DesignCreateWorkspaceInput): Promise { - const workspace = await this.requestJson('/api/design/workspaces', { + const created = record(await this.requestJson('/api/design/workspaces', { method: 'POST', body: JSON.stringify({ client_workspace_id: input.clientWorkspaceId, title: input.title, }), - }); - if (workspace.initial_conversation) this.rememberConversation(workspace.initial_conversation); - return this.getWorkspace(workspace.workspace_id); + })); + const snapshot = record(created.snapshot); + const form = mapForm(snapshot.form); + return this.getWorkspace(form.workspaceId); } async deleteWorkspace(workspaceId: string): Promise { @@ -920,133 +697,30 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { `/api/design/workspaces/${encodeURIComponent(workspaceId)}`, { method: 'DELETE' }, ); - this.forgetWorkspace(workspaceId); + this.closeWorkspaceSubscriptions(workspaceId); return { workspaceId, deleted: true }; } async renameWorkspace(input: DesignRenameWorkspaceInput): Promise { - const workspace = await this.requestJson( + await this.requestJson( `/api/design/workspaces/${encodeURIComponent(input.workspaceId)}`, - { - method: 'PATCH', - body: JSON.stringify({ title: input.title }), - }, + { method: 'PATCH', body: JSON.stringify({ title: input.title }) }, ); - return this.getWorkspace(workspace.workspace_id); + return this.getWorkspace(input.workspaceId); } async getWorkspace(workspaceId: string): Promise { - const [workspace, conversations] = await Promise.all([ - this.requestJson( - `/api/design/workspaces/${encodeURIComponent(workspaceId)}`, - ), - this.requestJson( - `/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations?limit=100&offset=0`, - ), - ]); - return mapWorkspace({ ...workspace, conversations }); + return mapWorkspace(await this.requestJson( + `/api/design/workspaces/${encodeURIComponent(workspaceId)}`, + )); } - async createConversation(input: DesignCreateConversationInput): Promise { - const conversation = await this.requestJson( - `/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/conversations`, - { - method: 'POST', - body: JSON.stringify({ - client_conversation_id: input.clientConversationId, - title: input.title, - }), - }, + async submitCommand(input: DesignCommandInput): Promise { + const command = await this.requestJson( + `/api/agents/sessions/${encodeURIComponent(input.sessionId)}/commands`, + { method: 'POST', body: JSON.stringify(agentCommand(input)) }, ); - this.rememberConversation(conversation); - return mapConversation(conversation); - } - - async getConversation( - workspaceId: string, - conversationId: string, - before?: string, - ): Promise { - const query = before ? `?before=${encodeURIComponent(before)}` : ''; - const conversation = await this.requestJson( - `/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}${query}`, - ); - this.rememberConversation(conversation); - return mapConversation(conversation); - } - - async submitMessage(input: DesignSubmitMessageInput): Promise { - const agentSessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId); - return this.executeAgentTurn({ - workspaceId: input.workspaceId, - conversationId: input.conversationId, - agentSessionId, - clientTurnId: input.clientTurnId, - expectedTurnRevision: input.expectedTurnRevision, - message: input.message, - attachmentAssetIds: input.attachmentAssetIds ?? [], - action: null, - }); - } - - async updateGenerationQuote( - input: DesignGenerationQuoteUpdateInput, - ): Promise { - const quote = await this.requestJson( - `/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/generation-quotes/${encodeURIComponent(input.quoteId)}`, - { - method: 'PATCH', - body: JSON.stringify({ - final_prompt: input.finalPrompt, - model: input.generationParameters.model, - resolution: input.generationParameters.resolution, - aspect_ratio: input.generationParameters.aspectRatio, - duration_seconds: input.generationParameters.durationSeconds, - }), - }, - ); - return mapQuote(quote)!; - } - - async confirmGeneration(input: DesignConfirmGenerationInput): Promise { - const agentSessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId); - // Prompt and generation parameters are persisted through the quote PATCH - // before confirmation. The Agent Runtime confirmation command only owns - // the immutable quote id and its aspect-ratio consistency check. - const action = { - type: 'confirm_generation' as const, - quote_id: input.quoteId, - aspect_ratio: input.generationParameters?.aspectRatio, - }; - return this.executeAgentTurn({ - workspaceId: input.workspaceId, - conversationId: input.conversationId, - agentSessionId, - clientTurnId: input.clientTurnId, - expectedTurnRevision: input.expectedTurnRevision, - message: '确认生成', - attachmentAssetIds: [], - action, - }); - } - - private async executeAgentTurn(input: AgentDesignTurnSubmission): Promise { - try { - return await this.executeAgentTurnOnce(input); - } catch (error) { - if (!isStaleAgentSessionError(error)) throw error; - this.forgetConversation(input.workspaceId, input.conversationId); - const agentSessionId = await this.getAgentSessionId( - input.workspaceId, - input.conversationId, - ); - return this.executeAgentTurnOnce({ ...input, agentSessionId }); - } - } - - private async executeAgentTurnOnce(input: AgentDesignTurnSubmission): Promise { - const command = await this.submitTurnCommand(input.agentSessionId, input); - const run = await this.waitForAgentRun(input.agentSessionId, command.run_id); + const run = await this.waitForRun(input.sessionId, command.run_id); if (run.status !== 'succeeded') { const code = run.error?.code ?? ( run.status === 'cancelled' ? 'design_agent_run_cancelled' : 'design_agent_run_failed' @@ -1057,151 +731,124 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { userFacingErrorMessage(code), ); } - return this.getConversation(input.workspaceId, input.conversationId); + return { + clientOperationId: input.clientOperationId, + runId: run.run_id, + workspace: await this.getWorkspace(input.workspaceId), + }; } - private async submitTurnCommand( - sessionId: string, - input: AgentDesignTurnSubmission, - ): Promise { - const command = { - client_command_id: input.clientTurnId, - name: 'turn.submit', - input: { - expected_turn_revision: input.expectedTurnRevision, - message: input.message, - attachment_asset_ids: input.attachmentAssetIds, - action: input.action, - }, - }; - const channel = this.findAgentCommandChannel(sessionId); - if (channel) { - try { - return await this.submitTurnCommandOverWebSocket(channel, command); - } catch (error) { - if (!(error instanceof AgentCommandTransportError)) throw error; - } + async uploadAsset(input: DesignAssetUploadInput): Promise { + const form = new FormData(); + form.append('file', new Blob([input.bytes], { type: input.mimeType }), input.fileName); + const asset = await this.requestJson( + `/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/assets`, + { method: 'POST', body: form }, + ); + return mapAsset(input.workspaceId, { ...record(asset), role: 'uploaded' }); + } + + async openWorkspaceEvents( + input: DesignWorkspaceEventSubscriptionInput, + ): Promise { + if (!this.eventSessionsEnabled) { + throw new DesignWorkspaceModuleError(503, 'DESIGN_EVENT_STREAM_PAUSED', '设计状态流已暂停'); } - return this.requestJson( - `/api/agents/sessions/${encodeURIComponent(sessionId)}/commands`, - { - method: 'POST', - body: JSON.stringify(command), - }, + const ticket = await this.requestJson( + `/api/agents/sessions/${encodeURIComponent(input.sessionId)}/stream-tickets`, + { method: 'POST', body: JSON.stringify({ transport: 'websocket' }) }, + ); + const streamUrl = new URL(ticket.stream_url, `${this.apiBaseUrl}/`); + if (streamUrl.origin !== new URL(this.apiBaseUrl).origin) { + throw new DesignWorkspaceModuleError(502, 'DESIGN_EVENT_STREAM_INVALID', '设计状态流地址无效'); + } + streamUrl.searchParams.set( + 'after_sequence', + String(eventSequence(input.afterEventId, input.sessionId)), + ); + streamUrl.protocol = streamUrl.protocol === 'https:' ? 'wss:' : 'ws:'; + const connection = await this.webSocketFactory(streamUrl.toString()); + const queue = createEventQueue(); + const { socket } = connection; + let opened = false; + let settled = false; + let heartbeat: ReturnType | null = null; + let openTimeout: ReturnType | null = null; + let resolveOpen!: () => void; + let rejectOpen!: (error: unknown) => void; + const openPromise = new Promise((resolve, reject) => { + resolveOpen = resolve; + rejectOpen = reject; + }); + const unregister = this.registerSubscription(input.workspaceId, () => close()); + const settle = (error: unknown | null) => { + if (settled) return; + settled = true; + unregister(); + if (heartbeat) clearInterval(heartbeat); + if (openTimeout) clearTimeout(openTimeout); + void connection.dispose?.(); + if (!opened) rejectOpen(error ?? new Error('Design stream closed')); + else if (error) queue.fail(error); + else queue.finish(); + }; + const close = () => { + if (settled) return; + try { + socket.close(1000, 'Client closed design event stream'); + } finally { + settle(null); + } + }; + socket.onopen = () => { + opened = true; + heartbeat = setInterval(() => { + if (socket.readyState !== WEBSOCKET_OPEN) return; + socket.send(JSON.stringify({ type: 'ping', request_id: `design-ping-${Date.now()}` })); + }, WEBSOCKET_PING_INTERVAL_MS); + resolveOpen(); + }; + socket.onmessage = ({ data }) => { + const event = normalizeWorkspaceEvent( + agentEventFromFrame(data), + input.sessionId, + input.workspaceId, + ); + if (event) queue.push(event); + }; + socket.onerror = () => settle( + new DesignWorkspaceModuleError(502, 'DESIGN_EVENT_STREAM_UNAVAILABLE', '设计状态连接失败'), + ); + socket.onclose = ({ code }) => settle(webSocketCloseError(code)); + openTimeout = setTimeout(() => { + settle(new DesignWorkspaceModuleError(504, 'DESIGN_EVENT_STREAM_TIMEOUT', '设计状态连接超时')); + try { + socket.close(1000, 'Timed out opening design event stream'); + } catch { + // settle above is authoritative. + } + }, WEBSOCKET_OPEN_TIMEOUT_MS); + await openPromise; + return { events: queue.events, close }; + } + + async closeEventSessions(): Promise { + this.eventSessionsEnabled = false; + const closers = [...this.eventSubscriptionClosers.values()].flatMap((set) => [...set]); + this.eventSubscriptionClosers.clear(); + closers.forEach((close) => close()); + } + + 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 findAgentCommandChannel(sessionId: string): AgentCommandChannel | null { - const channels = this.agentCommandChannels.get(sessionId); - if (!channels) return null; - for (const channel of channels) { - if (channel.socket.readyState === AGENT_WEBSOCKET_OPEN) return channel; - } - return null; - } - - private submitTurnCommandOverWebSocket( - channel: AgentCommandChannel, - command: Record, - ): Promise { - const requestId = `design-command-${Date.now()}-${this.nextAgentCommandRequestId += 1}`; - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - channel.waiters.delete(requestId); - reject(new AgentCommandTransportError('Agent WebSocket command acknowledgement timed out')); - }, AGENT_COMMAND_ACK_TIMEOUT_MS); - channel.waiters.set(requestId, { resolve, reject, timeout }); - try { - channel.socket.send(JSON.stringify({ - type: 'command.submit', - request_id: requestId, - command, - })); - } catch { - clearTimeout(timeout); - channel.waiters.delete(requestId); - reject(new AgentCommandTransportError('Agent WebSocket command send failed')); - } - }); - } - - private handleAgentCommandFrame(channel: AgentCommandChannel, data: unknown): void { - if (typeof data !== 'string') return; - let frame: Record; - try { - const parsed = JSON.parse(data) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; - frame = parsed as Record; - } catch { - return; - } - const requestId = frame.request_id; - if (typeof requestId !== 'string') return; - const waiter = channel.waiters.get(requestId); - if (!waiter) return; - - if (frame.type === 'command.accepted') { - const command = frame.command; - if (!command || typeof command !== 'object' || Array.isArray(command) - || typeof (command as Record).run_id !== 'string') { - return; - } - clearTimeout(waiter.timeout); - channel.waiters.delete(requestId); - waiter.resolve(command as ServerAgentCommand); - return; - } - if (frame.type !== 'error') return; - const error = frame.error; - if (!error || typeof error !== 'object' || Array.isArray(error)) return; - const code = (error as Record).code; - const message = (error as Record).message; - if (typeof code !== 'string' || typeof message !== 'string') return; - clearTimeout(waiter.timeout); - channel.waiters.delete(requestId); - waiter.reject(new DesignWorkspaceModuleError( - agentRunErrorStatus(code), - code, - userFacingErrorMessage(code), - )); - } - - private registerAgentCommandChannel( - sessionId: string, - channel: AgentCommandChannel, - ): void { - const channels = this.agentCommandChannels.get(sessionId) ?? new Set(); - channels.add(channel); - this.agentCommandChannels.set(sessionId, channels); - } - - private unregisterAgentCommandChannel( - sessionId: string, - channel: AgentCommandChannel, - ): void { - const channels = this.agentCommandChannels.get(sessionId); - channels?.delete(channel); - if (channels?.size === 0) this.agentCommandChannels.delete(sessionId); - for (const waiter of channel.waiters.values()) { - clearTimeout(waiter.timeout); - waiter.reject(new AgentCommandTransportError('Agent WebSocket connection closed')); - } - channel.waiters.clear(); - } - - private async waitForAgentRun(sessionId: string, runId: string): Promise { - const deadline = Date.now() + AGENT_RUN_TIMEOUT_MS; - const streamedRun = await this.waitForAgentRunEvent(sessionId, runId, deadline); - if (streamedRun) return streamedRun; - return this.pollAgentRun(sessionId, runId, deadline); - } - - private async pollAgentRun( - sessionId: string, - runId: string, - deadline: number, - ): Promise { - let pollInterval = AGENT_RUN_INITIAL_POLL_INTERVAL_MS; + private async waitForRun(sessionId: string, runId: string): Promise { + const deadline = Date.now() + RUN_TIMEOUT_MS; + let interval = RUN_INITIAL_POLL_MS; while (true) { const run = await this.requestJson( `/api/agents/sessions/${encodeURIComponent(sessionId)}/runs/${encodeURIComponent(runId)}`, @@ -1210,315 +857,27 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { return run; } if (Date.now() >= deadline) { - throw new DesignWorkspaceModuleError( - 504, - 'design_agent_run_timeout', - '设计 Agent 响应超时,请稍后重试', - ); + throw new DesignWorkspaceModuleError(504, 'design_agent_run_timeout', '设计 Agent 响应超时'); } - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - pollInterval = Math.min(pollInterval * 2, AGENT_RUN_MAX_POLL_INTERVAL_MS); + await new Promise((resolve) => setTimeout(resolve, interval)); + interval = Math.min(interval * 2, RUN_MAX_POLL_MS); } } - private waitForAgentRunEvent( - sessionId: string, - runId: string, - deadline: number, - ): Promise { - const key = `${sessionId}:${runId}`; - const cached = this.terminalAgentRuns.get(key); - if (cached) { - this.terminalAgentRuns.delete(key); - return Promise.resolve(cached); - } - if (!this.activeRunEventStreams.has(sessionId)) return Promise.resolve(null); - - return new Promise((resolve, reject) => { - let settled = false; - let timeout: ReturnType | null = null; - const cleanup = () => { - if (timeout !== null) clearTimeout(timeout); - const runWaiters = this.agentRunEventWaiters.get(key); - runWaiters?.delete(onRun); - if (runWaiters?.size === 0) this.agentRunEventWaiters.delete(key); - const streamWaiters = this.runStreamEndWaiters.get(sessionId); - streamWaiters?.delete(onStreamEnd); - if (streamWaiters?.size === 0) this.runStreamEndWaiters.delete(sessionId); - }; - const finish = (run: ServerAgentRun | null, error?: unknown) => { - if (settled) return; - settled = true; - cleanup(); - if (error) reject(error); - else resolve(run); - }; - const onRun: AgentRunEventWaiter = (run) => finish(run); - const onStreamEnd = () => finish(null); - const runWaiters = this.agentRunEventWaiters.get(key) ?? new Set(); - runWaiters.add(onRun); - this.agentRunEventWaiters.set(key, runWaiters); - const streamWaiters = this.runStreamEndWaiters.get(sessionId) ?? new Set<() => void>(); - streamWaiters.add(onStreamEnd); - this.runStreamEndWaiters.set(sessionId, streamWaiters); - timeout = setTimeout(() => finish(null, new DesignWorkspaceModuleError( - 504, - 'design_agent_run_timeout', - '设计 Agent 响应超时,请稍后重试', - )), Math.max(0, deadline - Date.now())); - - const racedRun = this.terminalAgentRuns.get(key); - if (racedRun) { - this.terminalAgentRuns.delete(key); - finish(racedRun); - } else if (!this.activeRunEventStreams.has(sessionId)) { - finish(null); - } - }); - } - - 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); - } - - async uploadAsset(input: DesignAssetUploadInput): Promise { - const form = new FormData(); - form.set( - 'file', - new Blob([Uint8Array.from(input.bytes)], { type: input.mimeType }), - input.fileName, - ); - const asset = await this.requestJson( - `/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/assets`, - { method: 'POST', body: form }, - ); - return mapAsset(input.workspaceId, asset); - } - - async openWorkspaceEvents( - input: DesignWorkspaceEventSubscriptionInput, - ): Promise { - if (!this.eventSessionsEnabled) { - throw new DesignWorkspaceModuleError( - 503, - 'DESIGN_EVENT_STREAM_PAUSED', - 'AI 设计任务状态流已暂停', - ); - } - let sessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId); - let ticket: ServerAgentStreamTicket; - try { - ticket = await this.createEventStreamTicket(sessionId); - } catch (error) { - if (!isStaleAgentSessionError(error)) { - throw error; - } - this.forgetConversation(input.workspaceId, input.conversationId); - sessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId); - ticket = await this.createEventStreamTicket(sessionId); - } - const streamUrl = new URL(ticket.stream_url, `${this.apiBaseUrl}/`); - if (streamUrl.origin !== new URL(this.apiBaseUrl).origin) { - throw new DesignWorkspaceModuleError( - 502, - 'DESIGN_EVENT_STREAM_INVALID', - 'AI 设计任务状态流地址无效', - ); - } - if (streamUrl.protocol !== 'http:' && streamUrl.protocol !== 'https:') { - throw new DesignWorkspaceModuleError( - 502, - 'DESIGN_EVENT_STREAM_INVALID', - 'AI 设计任务状态流协议无效', - ); - } - streamUrl.searchParams.set( - 'after_sequence', - String(eventSequence(input.afterEventId, sessionId)), - ); - streamUrl.protocol = streamUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - - let connection: AgentWebSocketConnection; - try { - connection = await this.webSocketFactory(streamUrl.toString()); - } catch { - throw new DesignWorkspaceModuleError( - 502, - 'DESIGN_EVENT_STREAM_UNAVAILABLE', - 'AI 设计任务状态连接失败', - ); - } - - const { socket } = connection; - const commandChannel: AgentCommandChannel = { socket, waiters: new Map() }; - const queue = createTaskEventQueue(); - let latestWorkspaceEventDelivery = Promise.resolve(); - let didOpen = false; - let ending = false; - let settled = false; - let heartbeat: ReturnType | null = null; - let openedTimeout: ReturnType | null = null; - let unregister = () => undefined; - let resolveOpened: () => void = () => undefined; - let rejectOpened: (error: unknown) => void = () => undefined; - const opened = new Promise((resolve, reject) => { - resolveOpened = resolve; - rejectOpened = reject; - }); - const unavailableError = () => new DesignWorkspaceModuleError( - 502, - 'DESIGN_EVENT_STREAM_UNAVAILABLE', - 'AI 设计任务状态连接已断开', - ); - const settle = async (error: unknown | null): Promise => { - if (settled) return; - settled = true; - socket.onopen = null; - socket.onmessage = null; - socket.onerror = null; - socket.onclose = null; - if (heartbeat !== null) clearInterval(heartbeat); - heartbeat = null; - if (openedTimeout !== null) clearTimeout(openedTimeout); - openedTimeout = null; - unregister(); - if (didOpen) { - this.unregisterAgentCommandChannel(sessionId, commandChannel); - this.unregisterRunEventStream(sessionId); - } - try { - await connection.dispose?.(); - } catch { - // The stream outcome is authoritative; dispatcher cleanup is best effort. - } - if (!didOpen) { - rejectOpened(error ?? unavailableError()); - } else if (error) { - queue.fail(error); - } else { - queue.finish(); - } - }; - const beginEnd = ( - error: unknown | null, - cleanup?: () => void | Promise, - ): void => { - if (ending) return; - ending = true; - void (async () => { - let finalError = error; - try { - await cleanup?.(); - } catch (cleanupError) { - finalError ??= cleanupError; - } - await settle(finalError); - })(); - }; - const close = (): void => { - if (ending) return; - beginEnd(null); - try { - socket.close(1000, 'Client closed design event stream'); - } catch { - // The local queue is already closed. - } - }; - - socket.onopen = () => { - if (ending) return; - didOpen = true; - this.registerRunEventStream(sessionId); - this.registerAgentCommandChannel(sessionId, commandChannel); - heartbeat = setInterval(() => { - if (ending || socket.readyState !== AGENT_WEBSOCKET_OPEN) return; - try { - socket.send(JSON.stringify({ - type: 'ping', - request_id: `design-ping-${Date.now()}`, - })); - } catch { - beginEnd(unavailableError()); - } - }, AGENT_WEBSOCKET_PING_INTERVAL_MS); - resolveOpened(); - }; - socket.onmessage = ({ data }) => { - if (ending) return; - this.handleAgentCommandFrame(commandChannel, data); - const agentEvent = agentEventFromWebSocketFrame(data); - const event = normalizeWorkspaceEvent( - agentEvent, - sessionId, - input.workspaceId, - input.conversationId, - ); - if (event) { - latestWorkspaceEventDelivery = boundedTaskEventDelivery(queue.push(event)); - } - const run = normalizeAgentRunEvent(agentEvent, sessionId); - if (run) { - const deliveryBarrier = latestWorkspaceEventDelivery; - void deliveryBarrier.then(() => this.publishAgentRun(sessionId, run)); - } - }; - socket.onerror = () => { - setTimeout(() => { - if (!ending) beginEnd(unavailableError()); - }, 0); - }; - socket.onclose = ({ code }) => { - const error = webSocketCloseError(code); - if (code === 4409 || code === 4404) { - beginEnd(error, () => { - this.forgetConversation(input.workspaceId, input.conversationId); - }); - return; - } - beginEnd(error); - }; - unregister = this.registerEventSubscription( - input.workspaceId, - input.conversationId, - close, - ); - openedTimeout = setTimeout(() => { - if (ending || didOpen) return; - beginEnd(new DesignWorkspaceModuleError( - 504, - 'DESIGN_EVENT_STREAM_TIMEOUT', - 'AI 设计任务状态连接超时,请稍后重试', - )); - try { - socket.close(1000, 'Timed out opening design event stream'); - } catch { - // settle below remains authoritative when the transport cannot close. - } - }, AGENT_WEBSOCKET_OPEN_TIMEOUT_MS); - await opened; - return { - events: queue.events, - close, + private registerSubscription(workspaceId: string, close: () => void): () => void { + const closers = this.eventSubscriptionClosers.get(workspaceId) ?? new Set<() => void>(); + closers.add(close); + this.eventSubscriptionClosers.set(workspaceId, closers); + return () => { + closers.delete(close); + if (!closers.size) this.eventSubscriptionClosers.delete(workspaceId); }; } - async closeEventSessions(): Promise { - this.eventSessionsEnabled = false; - const activeSubscriptions = [...this.eventSubscriptionClosers.values()] - .flatMap((closers) => [...closers]); - this.eventSubscriptionClosers.clear(); - for (const close of activeSubscriptions) close(); - this.conversationSessionIds.clear(); - } - - 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 closeWorkspaceSubscriptions(workspaceId: string): void { + const closers = [...(this.eventSubscriptionClosers.get(workspaceId) ?? [])]; + this.eventSubscriptionClosers.delete(workspaceId); + closers.forEach((close) => close()); } private async requestJson(path: string, init: RequestInit = {}): Promise { @@ -1544,15 +903,15 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { throw new DesignWorkspaceModuleError( response.status, code, - userFacingErrorMessage(code), + typeof detail.message === 'string' + ? detail.message + : userFacingErrorMessage(code), ); } return payload as T; }, this.requestTimeoutMs, init.signal); } catch (error) { - if (error instanceof RequestDeadlineExceededError) { - throw designRequestTimeoutError(); - } + if (error instanceof RequestDeadlineExceededError) throw designRequestTimeoutError(); throw error; } } @@ -1566,10 +925,8 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { 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, @@ -1581,9 +938,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { response = await this.fetchWithToken(path, token, init); return response; } catch (error) { - if (error instanceof RequestDeadlineExceededError) { - throw designRequestTimeoutError(); - } + if (error instanceof RequestDeadlineExceededError) throw designRequestTimeoutError(); throw error; } } @@ -1591,139 +946,15 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { private fetchWithToken(path: string, token: string, init: RequestInit): Promise { const requestInit: RequestInit = { ...init, - headers: { - ...init.headers, - Authorization: `Bearer ${token}`, - }, + headers: { ...init.headers, Authorization: `Bearer ${token}` }, }; - if (init.signal) { - return this.fetchImpl(`${this.apiBaseUrl}${path}`, requestInit); - } - return fetchWithDeadline( - this.fetchImpl, - `${this.apiBaseUrl}${path}`, - requestInit, - this.requestTimeoutMs, - ); - } - - private conversationKey(workspaceId: string, conversationId: string): string { - return `${workspaceId}:${conversationId}`; - } - - private rememberConversation(conversation: ServerConversationSummary): void { - if (typeof conversation.agent_session_id !== 'string' - || !conversation.agent_session_id.trim()) return; - this.conversationSessionIds.set( - this.conversationKey(conversation.workspace_id, conversation.conversation_id), - conversation.agent_session_id, - ); - } - - private forgetConversation(workspaceId: string, conversationId: string): void { - this.conversationSessionIds.delete(this.conversationKey(workspaceId, conversationId)); - } - - private forgetWorkspace(workspaceId: string): void { - const keyPrefix = `${workspaceId}:`; - const sessionIds = new Set(); - for (const [key, sessionId] of this.conversationSessionIds) { - if (!key.startsWith(keyPrefix)) continue; - sessionIds.add(sessionId); - this.conversationSessionIds.delete(key); - } - - const activeSubscriptions: Array<() => void> = []; - for (const [key, closers] of this.eventSubscriptionClosers) { - if (!key.startsWith(keyPrefix)) continue; - activeSubscriptions.push(...closers); - this.eventSubscriptionClosers.delete(key); - } - for (const close of activeSubscriptions) close(); - - for (const key of this.terminalAgentRuns.keys()) { - if ([...sessionIds].some((sessionId) => key.startsWith(`${sessionId}:`))) { - this.terminalAgentRuns.delete(key); - } - } - } - - private async getAgentSessionId( - workspaceId: string, - conversationId: string, - ): Promise { - const key = this.conversationKey(workspaceId, conversationId); - const existing = this.conversationSessionIds.get(key); - if (existing) return existing; - await this.getConversation(workspaceId, conversationId); - const refreshed = this.conversationSessionIds.get(key); - if (!refreshed) { - throw new DesignWorkspaceModuleError( - 503, - 'DESIGN_CONVERSATION_SESSION_UNAVAILABLE', - '设计会话暂时无法连接,请稍后重试', + return init.signal + ? this.fetchImpl(`${this.apiBaseUrl}${path}`, requestInit) + : fetchWithDeadline( + this.fetchImpl, + `${this.apiBaseUrl}${path}`, + requestInit, + this.requestTimeoutMs, ); - } - return refreshed; } - - private createEventStreamTicket(sessionId: string): Promise { - return this.requestJson( - `/api/agents/sessions/${encodeURIComponent(sessionId)}/stream-tickets`, - { - method: 'POST', - body: JSON.stringify({ transport: 'websocket' }), - }, - ); - } - - private registerEventSubscription( - workspaceId: string, - conversationId: string, - close: () => void, - ): () => void { - const key = this.conversationKey(workspaceId, conversationId); - const closers = this.eventSubscriptionClosers.get(key) ?? new Set<() => void>(); - closers.add(close); - this.eventSubscriptionClosers.set(key, closers); - return () => { - closers.delete(close); - if (closers.size === 0) this.eventSubscriptionClosers.delete(key); - }; - } - - private registerRunEventStream(sessionId: string): void { - this.activeRunEventStreams.set( - sessionId, - (this.activeRunEventStreams.get(sessionId) ?? 0) + 1, - ); - } - - private unregisterRunEventStream(sessionId: string): void { - const remaining = (this.activeRunEventStreams.get(sessionId) ?? 1) - 1; - if (remaining > 0) { - this.activeRunEventStreams.set(sessionId, remaining); - return; - } - this.activeRunEventStreams.delete(sessionId); - const waiters = this.runStreamEndWaiters.get(sessionId); - this.runStreamEndWaiters.delete(sessionId); - for (const resolve of waiters ?? []) resolve(); - } - - private publishAgentRun(sessionId: string, run: ServerAgentRun): void { - const key = `${sessionId}:${run.run_id}`; - const waiters = this.agentRunEventWaiters.get(key); - if (!waiters?.size) { - this.terminalAgentRuns.set(key, run); - if (this.terminalAgentRuns.size > 100) { - const oldest = this.terminalAgentRuns.keys().next().value; - if (oldest) this.terminalAgentRuns.delete(oldest); - } - return; - } - this.agentRunEventWaiters.delete(key); - for (const resolve of waiters) resolve(run); - } - } diff --git a/electron/main/index.ts b/electron/main/index.ts index 292c76e..4d47b53 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -71,10 +71,6 @@ import { shouldUseSecureWorksSquareSessionPersistence } from '../services/works- import { initializeRememberedPassword } from '../services/remembered-password'; import { clearManagedWorksSquareRuntimeBestEffort } from '../services/works-square-runtime'; import { initializeMeowaGameAssetsCredential } from '../api/routes/meowa-game-assets'; -import { - isLocalImageWorkspaceDevelopmentEnabled, - LocalImageWorkspace, -} from '../image-workspace/local-workspace'; import { WorksSquareDesignWorkspace } from '../image-workspace/works-square-workspace'; import type { DesignWorkspaceModule } from '../image-workspace/module'; import { @@ -500,20 +496,9 @@ async function initialize(): Promise { codingProjectStore = createCodingProjectStore(codingProjectStorage); worksSubmissionBinding = createWorksSubmissionBindingStore(codingProjectStore); - const localImageWorkspaceEnabled = isLocalImageWorkspaceDevelopmentEnabled({ - isPackaged: app.isPackaged, - configuredMode: process.env.NIANCODE_IMAGE_WORKSPACE_MODE, - isDevelopmentServer: Boolean(process.env.VITE_DEV_SERVER_URL), - }); - const imageWorkspace = localImageWorkspaceEnabled - ? new LocalImageWorkspace({ userDataDir: app.getPath('userData') }) - : new WorksSquareDesignWorkspace(); + const imageWorkspace = new WorksSquareDesignWorkspace(); imageWorkspaceModule = imageWorkspace; - 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'); - } + logger.info('AI painting workspace is using the Works Square V2 cloud contract'); // Set application menu createMenu(); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index cf29ee5..2da908c 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -5,9 +5,6 @@ import { contextBridge, ipcRenderer } from 'electron'; const isDevelopment = process.env.NODE_ENV === 'development' || Boolean(process.env.VITE_DEV_SERVER_URL); -const imageWorkspaceMode = process.env.NIANCODE_IMAGE_WORKSPACE_MODE?.trim().toLowerCase(); -const imageWorkspaceLocalDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL) - && (imageWorkspaceMode === 'local' || !imageWorkspaceMode); const validInvokeChannels = [ 'hostapi:fetch', @@ -162,10 +159,6 @@ const electronAPI = { */ isDev: isDevelopment, - /** - * Narrow anonymous-route exception for the explicit unpackaged image workspace adapter. - */ - imageWorkspaceLocalDevelopment, }; // Expose the API to the renderer process diff --git a/package.json b/package.json index a71e736..531c81b 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,7 @@ "scripts": { "postinstall": "node -e \"process.exit(0)\"", "init": "pnpm install", - "dev": "node scripts/run-cloud-image-workspace-dev.mjs", - "dev:image-workspace:local": "node scripts/run-local-image-workspace-dev.mjs", + "dev": "vite", "build": "pnpm run build:vite && node scripts/bundle-pi-runtime.mjs --release-targets && node scripts/run-electron-builder.mjs", "build:vite": "node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build", "perf:budget": "node scripts/check-performance-budget.mjs", diff --git a/scripts/run-cloud-image-workspace-dev.mjs b/scripts/run-cloud-image-workspace-dev.mjs deleted file mode 100644 index 4170271..0000000 --- a/scripts/run-cloud-image-workspace-dev.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import { spawn } from 'node:child_process'; -import { resolve } from 'node:path'; - -const viteEntry = resolve(process.cwd(), 'node_modules/vite/bin/vite.js'); -const child = spawn(process.execPath, [viteEntry, ...process.argv.slice(2)], { - stdio: 'inherit', - env: { - ...process.env, - NIANCODE_IMAGE_WORKSPACE_MODE: 'cloud', - }, -}); - -for (const signal of ['SIGINT', 'SIGTERM']) { - process.on(signal, () => { - child.kill(signal); - }); -} - -child.on('exit', (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exitCode = code ?? 1; -}); diff --git a/scripts/run-local-image-workspace-dev.mjs b/scripts/run-local-image-workspace-dev.mjs deleted file mode 100644 index ee14347..0000000 --- a/scripts/run-local-image-workspace-dev.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import { spawn } from 'node:child_process'; -import { resolve } from 'node:path'; - -const viteEntry = resolve(process.cwd(), 'node_modules/vite/bin/vite.js'); -const child = spawn(process.execPath, [viteEntry, ...process.argv.slice(2)], { - stdio: 'inherit', - env: { - ...process.env, - NIANCODE_IMAGE_WORKSPACE_MODE: 'local', - }, -}); - -for (const signal of ['SIGINT', 'SIGTERM']) { - process.on(signal, () => { - child.kill(signal); - }); -} - -child.on('exit', (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exitCode = code ?? 1; -}); diff --git a/shared/image-workspace.ts b/shared/image-workspace.ts index 4647293..3683cde 100644 --- a/shared/image-workspace.ts +++ b/shared/image-workspace.ts @@ -3,9 +3,17 @@ export const IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE = 'AI 设计暂不可用'; export const IMAGE_WORKSPACE_API_PATH = '/api/works/image-workspace'; 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 DesignResolution = 'open' | 'proposed' | 'resolved' | 'delegated' | 'not_applicable'; +export type DesignProvenance = + | 'user' + | 'extracted_user' + | 'agent_proposal' + | 'delegated_agent' + | 'deterministic' + | 'inherited' + | 'reference_observation' + | 'restored' + | 'legacy_import'; export type DesignCapabilities = { conversation: boolean; @@ -14,181 +22,309 @@ export type DesignCapabilities = { video: boolean; }; -export type DesignBrief = { - version: number; - status: 'draft' | 'ready' | 'confirmed'; - medium: DesignMedium | null; - summary: string; - ready: boolean; - missingDecision: string | null; +export type DesignFieldDecision = { + resolution: DesignResolution; + provenance: DesignProvenance; + locked: boolean; + changed_by: string; + origin_turn_id: string | null; }; -export type DesignGenerationOption = { - value: TValue; +export type DesignSpecificationValues = { + intent: { + media: DesignMedium | null; + creation_intent: 'create' | 'edit' | 'vary' | 'composite' | 'animate' | 'extend' | null; + purpose: string | null; + channel: string | null; + audience: string | null; + intended_response: string | null; + communication_goal: string | null; + success_definition: string | null; + related_direction_id: string | null; + related_asset_id: string | null; + }; + content: { + concept: string | null; + narrative: string | null; + subjects: Array<{ + id: string; + description: string; + attributes: string[]; + action: string | null; + spatial_relationship: string | null; + }>; + action: string | null; + environment: string | null; + emotional_tone: string | null; + exact_content: Array<{ + id: string; + text: string; + role: string | null; + language: string | null; + hierarchy: string | null; + exact: boolean; + }>; + }; + visual: { + art_direction: string | null; + cultural_cues: string | null; + composition: string | null; + viewpoint: string | null; + lighting: string | null; + palette: string | null; + materials: string | null; + finish: string | null; + }; + layout: { + regions: Array<{ id: string; role: string; placement: string; priority: number }>; + subject_placement: string | null; + text_placement: string | null; + logo_placement: string | null; + safe_areas: string | null; + crop_behavior: string | null; + typography: string | null; + exact_rendering: string | null; + }; + references: Array<{ + id: string; + asset_id: string; + asset_revision: number | null; + role: string; + preserve: string[]; + adapt: string[]; + do_not_copy: string[]; + reviewed_observations: string[]; + }>; + image: { + operation_intent: string | null; + source_asset_id: string | null; + mask_asset_id: string | null; + preserve_instructions: string[]; + change_instructions: string[]; + background_behavior: string | null; + transparency_required: boolean | null; + edge_crop_behavior: string | null; + variant_relationship: string | null; + }; + video: { + total_duration_seconds: number | null; + pacing: string | null; + first_frame_asset_id: string | null; + last_frame_asset_id: string | null; + shots: Array<{ + id: string; + start_milliseconds: number | null; + end_milliseconds: number | null; + story_beat: string; + framing: string | null; + camera: string | null; + subject_motion: string | null; + environment_motion: string | null; + focus_behavior: string | null; + transition: string | null; + }>; + rhythm: string | null; + speed_behavior: string | null; + stillness: string | null; + loop_behavior: string | null; + end_state: string | null; + continuity: string | null; + audio_intent: string | null; + }; + output: { + aspect_ratio: string | null; + orientation: string | null; + quality_intent: string | null; + delivery_format: string | null; + variant_count: number | null; + variant_purposes: string[]; + crop_requirements: string | null; + export_requirements: string | null; + safe_areas: string | null; + }; + constraints: { + must_include: string[]; + must_preserve: string[]; + must_avoid: string[]; + brand_constraints: string[]; + flexible_preferences: string[]; + acceptance_criteria: Array<{ id: string; description: string; required: boolean }>; + }; +}; + +export type DesignSpecification = { + schema_version: 1; + values: DesignSpecificationValues; + field_decisions: Record; +}; + +export type DesignFieldChange = { + path: string; + kind: 'set' | 'update' | 'clear' | 'decision' | 'lock' | 'unlock'; + before_value?: unknown; + after_value?: unknown; + before_decision?: DesignFieldDecision | null; + after_decision?: DesignFieldDecision | null; +}; + +export type DesignChangeSet = { + interaction_id: string; + changes: DesignFieldChange[]; +}; + +export type DesignDecisionOption = { + id: string; label: string; - default: boolean; - disabled: boolean; - multiplier: number; + description: string | null; }; -export type DesignGenerationParameters = { - model: string; - resolution: string; +export type DesignDecisionPrompt = { + id: string; + kind: 'suggestion' | 'question'; + basedOnSpecificationRevisionId: string; + targetPaths: string[]; + title: string; + body: string; + options: DesignDecisionOption[]; +}; + +export type DesignQuoteOutputSummary = { + medium: DesignMedium; + strategy: 'direct' | 'multi_step'; aspectRatio: string; + outputCount: number; + deliveryFormat: string | null; durationSeconds: number | null; -}; - -export type DesignGenerationOptions = { - models: DesignGenerationOption[]; - resolutions: DesignGenerationOption[]; - durations: DesignGenerationOption[]; - aspectRatios: DesignGenerationOption[]; -}; - -export type DesignGenerationPricing = { - schema: string; - amount: number; - rounding: string; + requiredOutputRoles: string[]; }; export type DesignGenerationQuote = { quoteId: string; - status: DesignQuoteStatus; + status: 'offered' | 'consumed' | 'expired' | 'superseded'; + specificationRevision: number; + specificationRevisionId: string; + specificationDigest: string; medium: DesignMedium; - briefVersion: number; - briefSummary: string; - finalPrompt: string; - promptMode: string; - generationParameters: DesignGenerationParameters; - generationOptions: DesignGenerationOptions; - pricing: DesignGenerationPricing; + compilerVersion: string; + outputSummary: DesignQuoteOutputSummary; + warnings: DesignCompilationIssue[]; quotedDesignPoints: number; + maximumCustomerChargeAtoms: number; expiresAt: string; }; -export type DesignMessage = { - id: string; - role: 'user' | 'assistant'; - kind: 'user' | 'reply' | 'choice' | 'confirmation' | 'safety_redirect' | 'failure'; - text: string; - quickReplies: string[]; - generationQuote: DesignGenerationQuote | null; - turnRevision: number; - createdAt: string; +export type DesignForm = { + schemaVersion: 1; + workspaceId: string; + directionId: string; + directionRevision: number; + rawTurnSequence: number; + specificationRevision: number; + workspaceViewRevision: number; + specificationRevisionId: string; + specificationDigest: string; + specification: DesignSpecification; + decisionPrompts: DesignDecisionPrompt[]; + activeQuotes: DesignGenerationQuote[]; + recentChangeSet: DesignChangeSet; +}; + +export type DesignTurn = { + turnId: string; + rawTurnSequence: number; + userMessage: string; + assistantMessage: string; }; export type DesignWorkspaceSummary = { workspaceId: string; + clientWorkspaceId: string; title: string; - viewRevision: number; - conversationCount: number; - phase: DesignWorkspacePhase; + directionId: string; + sessionId: string; + directionRevision: number; + specificationRevision: number; + workspaceViewRevision: number; updatedAt: string; }; -export type DesignWorkspace = DesignWorkspaceSummary & { - conversations: DesignConversationSummary[]; -}; - -export type DesignConversationSummary = { - conversationId: string; - workspaceId: string; - title: string; - /** A compact preview for history navigation; full messages are loaded on selection. */ - latestMessagePreview?: string | null; - turnRevision: number; - phase: DesignWorkspacePhase; - brief: DesignBrief; - createdAt: string; - updatedAt: string; -}; - -export type DesignConversation = DesignConversationSummary & { - messages: DesignMessage[]; - /** Cursor for the next older message page; absent only for legacy adapters. */ - messagePage?: DesignConversationMessagePage; -}; - -export type DesignConversationMessagePage = { - hasOlder: boolean; - nextBefore: string | null; -}; - export type DesignAsset = { assetId: string; workspaceId: string; + role: 'uploaded' | 'generated'; mediaType: DesignMedium; mimeType: string; width: number; height: number; durationMilliseconds: number | null; + generationTaskId: string | null; createdAt: string; contentPath: string; }; -export type DesignAssetSaveResult = { - status: 'saved' | 'cancelled'; -}; - -export type DesignImageUploadMimeType = 'image/jpeg' | 'image/png' | 'image/webp'; - -export type DesignAssetUploadInput = { - workspaceId: string; - fileName: string; - mimeType: DesignImageUploadMimeType; - bytes: Uint8Array; +export type DesignTaskProgress = { + stage: + | 'queued' + | 'preparing' + | 'submitting' + | 'generating' + | 'composing' + | 'validating' + | 'finalizing' + | 'completed' + | 'failed' + | 'cancelled'; + completedRequiredSteps: number; + totalRequiredSteps: number; }; export type DesignGenerationTask = { taskId: string; - workspaceId: string; - conversationId: string | null; + quoteId: string; + status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; + taskRevision: number; + specificationRevision: number; + specificationRevisionId: string; + specificationDigest: string; medium: DesignMedium; - status: DesignTaskStatus; - briefVersion: number; - briefSummary: string; - quoteId: string | null; - quotedDesignPoints: number | null; - failureCode: string | null; - resultAssets: DesignAsset[]; + compilerVersion: string; + outputSummary: DesignQuoteOutputSummary; + maximumCustomerChargeAtoms: number; + customerBilling: { + quotedAtoms: number; + heldAtoms: number; + chargedAtoms: number; + refundedAtoms: number; + pendingResolutionAtoms: number; + }; + customerHoldExpiresAt: string; + resolutionDeadlineAt: string | null; + progress: DesignTaskProgress; + executionHealth: 'normal' | 'recovering' | 'operator_required'; + cancellation: { + state: 'available' | 'requested' | 'resolved' | 'unavailable'; + outcome: 'stopped_before_submit' | null; + }; + issues: Array<{ + code: string; + message: string; + recoveryOwner: 'platform' | 'operator'; + }>; + resultAssetIds: string[]; + nextActions: Array< + 'cancel' | 'view_result' | 'revise_design' | 'generate_again' | 'wait_for_resolution' + >; createdAt: string; - updatedAt: string; }; -export type DesignGenerationTaskUpdatedEvent = { - id: string; - type: 'design.generation_task.updated'; - workspaceId: string; - workspaceViewRevision: number; - generationTask: DesignGenerationTask; +export type DesignWorkspace = { + workspace: DesignWorkspaceSummary; + form: DesignForm; + turns: DesignTurn[]; + tasks: DesignGenerationTask[]; + assets: DesignAsset[]; }; -export type DesignConversationSnapshotEvent = { - id: string; - type: 'design.conversation.snapshot'; - workspaceId: string; - conversationId: string; - workspaceViewRevision: number; - conversation: DesignConversation; - generationTasks: DesignGenerationTask[]; -}; - -export type DesignAssistantDeltaEvent = { - id: string; - type: 'design.assistant.delta'; - workspaceId: string; - conversationId: string; - clientTurnId: string; - turnRevision: number; - chunkIndex: number; - delta: string; -}; - -export type DesignWorkspaceEvent = - | DesignGenerationTaskUpdatedEvent - | DesignConversationSnapshotEvent - | DesignAssistantDeltaEvent; - export type DesignWorkspaceBootstrap = { capabilities: DesignCapabilities; workspaces: DesignWorkspaceSummary[]; @@ -199,46 +335,173 @@ export type DesignCreateWorkspaceInput = { title: string; }; -export type DesignDeleteWorkspaceResult = { - workspaceId: string; - deleted: true; -}; - -export type DesignCreateConversationInput = { - workspaceId: string; - clientConversationId: string; - title: string; -}; - export type DesignRenameWorkspaceInput = { workspaceId: string; title: string; }; -export type DesignSubmitMessageInput = { +export type DesignDeleteWorkspaceResult = { workspaceId: string; - conversationId: string; - clientTurnId: string; - expectedTurnRevision: number; + deleted: true; +}; + +export type DesignUserFieldOperation = + | { kind: 'set'; path: string; value: unknown } + | { kind: 'clear'; path: string; resolution?: 'open' | 'not_applicable' } + | { kind: 'set_lock'; path: string; locked: boolean }; + +export type DesignUserInput = + | { kind: 'direct_edit'; operations: DesignUserFieldOperation[] } + | { kind: 'chat'; message: string } + | { + kind: 'prompt_resolution'; + promptId: string; + action: 'select' | 'reject'; + optionId?: string; + } + | { kind: 'restore'; specificationRevisionId: string }; + +export type DesignCommandInput = + | { + kind: 'apply_input'; + workspaceId: string; + sessionId: string; + expectedDirectionRevision: number; + clientOperationId: string; + input: DesignUserInput; + } + | { + kind: 'request_quote'; + workspaceId: string; + sessionId: string; + expectedDirectionRevision: number; + specificationRevision: number; + clientOperationId: string; + } + | { + kind: 'confirm_generation'; + workspaceId: string; + sessionId: string; + quoteId: string; + expectedDirectionRevision: number; + clientOperationId: string; + }; + +export type DesignCommandResult = { + clientOperationId: string; + runId: string; + workspace: DesignWorkspace; +}; + +export type DesignCompilationIssue = { + code: string; message: string; - attachmentAssetIds?: string[]; + severity: 'blocker' | 'warning'; + path?: string | null; }; -export type DesignGenerationQuoteUpdateInput = { - workspaceId: string; - quoteId: string; - finalPrompt: string; - generationParameters: DesignGenerationParameters; +export type DesignInteractionOutcome = { + operationKind: + | 'bootstrap' + | 'legacy_import' + | 'direct_edit' + | 'chat' + | 'prompt_resolution' + | 'restore' + | 'quote_request' + | 'generation_confirmation'; + interactionId: string; + baseDirectionRevision: number; + newDirectionRevision: number; + rawTurnSequence: number; + specificationRevision: number; + specificationRevisionId: string; + workspaceViewRevision: number; + specificationRevisionCreated: boolean; + meaningChanged: boolean; + changeSet: DesignChangeSet; + turnId: string | null; + assistantMessage: string | null; + createdDecisionPromptIds: string[]; + resolvedDecisionPromptId: string | null; + supersededDecisionPromptIds: string[]; + supersededQuoteCount: number; + quoteId: string | null; + generationTaskId: string | null; }; -export type DesignConfirmGenerationInput = { +export type DesignSessionSnapshotEvent = { + id: string; + type: 'design.session.snapshot'; + form: DesignForm; +}; + +export type DesignAssistantDeltaEvent = { + id: string; + type: 'design.assistant.delta'; workspaceId: string; - conversationId: string; - clientTurnId: string; - expectedTurnRevision: number; - quoteId: string; - finalPrompt: string; - generationParameters: DesignGenerationParameters; + directionId: string; + clientOperationId: string; + directionRevision: number; + chunkIndex: number; + delta: string; +}; + +export type DesignDirectionUpdatedEvent = { + id: string; + type: 'design.direction.updated'; + replayed: boolean; + operation: DesignInteractionOutcome; + form: DesignForm; +}; + +export type DesignQuoteBlockedEvent = { + id: string; + type: 'design.quote.blocked'; + clientOperationId: string; + blockers: DesignCompilationIssue[]; + warnings: DesignCompilationIssue[]; + form: DesignForm; +}; + +export type DesignWorkspaceUpdatedEvent = { + id: string; + type: 'design.workspace.updated'; + workspaceId: string; + workspaceViewRevision: number; + changedDirection: { + directionId: string; + directionRevision: number; + specificationRevision: number; + } | null; + changedTask: DesignGenerationTask | null; +}; + +export type DesignWorkspaceEvent = + | DesignSessionSnapshotEvent + | DesignAssistantDeltaEvent + | DesignDirectionUpdatedEvent + | DesignQuoteBlockedEvent + | DesignWorkspaceUpdatedEvent; + +export type DesignWorkspaceEventSubscription = { + events: AsyncIterable; + close(): void; +}; + +export type DesignWorkspaceEventSubscriptionInput = { + workspaceId: string; + sessionId: string; + afterEventId?: string; +}; + +export type DesignAssetSaveResult = { status: 'saved' | 'cancelled' }; +export type DesignImageUploadMimeType = 'image/jpeg' | 'image/png' | 'image/webp'; +export type DesignAssetUploadInput = { + workspaceId: string; + fileName: string; + mimeType: DesignImageUploadMimeType; + bytes: Uint8Array; }; export function designAssetContentPath(workspaceId: string, assetId: string): string { diff --git a/src/App.tsx b/src/App.tsx index 3b7f9fd..8fbeeea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -146,33 +146,25 @@ function publishDesktopActivity(input: { function ProtectedLayout({ authReady, authRequired, - imageWorkspaceLocalDevelopment, setupReady, }: { authReady: boolean; authRequired: boolean; - imageWorkspaceLocalDevelopment: boolean; setupReady: boolean; }) { const location = useLocation(); const authenticated = useAuthStore((state) => state.isAuthenticated()); const moduleAccess = useAuthStore((state) => state.moduleAccess); const requestedModule = getGuardedAiModuleForPath(location.pathname); - const allowsAnonymousImageWorkspace = imageWorkspaceLocalDevelopment - && (location.pathname === '/image-canvas' - || location.pathname.startsWith('/image-canvas/') - || location.pathname === '/image-prompts' - || location.pathname.startsWith('/image-prompts/')); - if (!setupReady) { return ; } - if (authRequired && !authReady && !allowsAnonymousImageWorkspace) { + if (authRequired && !authReady) { return ; } - if (authRequired && !allowsAnonymousImageWorkspace && !authenticated) { + if (authRequired && !authenticated) { return ( state.bootstrap); const setupReady = setupComplete || skipSetupForE2E || rendererOnlyPreview; const authRequired = !skipSetupForE2E && !rendererOnlyPreview; - const imageWorkspaceLocalDevelopment = window.electron?.imageWorkspaceLocalDevelopment === true; useEffect(() => installRendererPerformanceDiagnostics(), []); @@ -447,7 +438,6 @@ function App() { )} diff --git a/src/components/layout/ImageWorkspaceSidebar.tsx b/src/components/layout/ImageWorkspaceSidebar.tsx index 0a0e483..5d6ae5a 100644 --- a/src/components/layout/ImageWorkspaceSidebar.tsx +++ b/src/components/layout/ImageWorkspaceSidebar.tsx @@ -1,21 +1,16 @@ import { useEffect, useMemo, useState } from 'react'; import { - ChevronDown, - ChevronRight, FolderKanban, Lightbulb, Loader2, - MessageSquareText, Pencil, Plus, RefreshCw, Trash2, - X, } from 'lucide-react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; -import { DisclosureContent } from '@/components/ui/disclosure'; import { Dialog, DialogContent, @@ -23,14 +18,12 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace'; import { cn } from '@/lib/utils'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import { useAuthStore } from '@/stores/auth'; -import type { - DesignConversationSummary, - DesignWorkspaceSummary, -} from '../../../shared/image-workspace'; +import type { DesignWorkspaceSummary } from '../../../shared/image-workspace'; type ImageWorkspaceSidebarProps = { sidebarCollapsed: boolean; @@ -40,18 +33,9 @@ type ProjectDialogState = | { mode: 'create'; project: null } | { mode: 'rename'; project: DesignWorkspaceSummary }; -const VISIBLE_CONVERSATION_LIMIT = 5; -const CONVERSATION_PREVIEW_LIMIT = 28; -const PROJECT_ACTION_BUTTON_CLASS = 'relative z-10 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground pointer-events-none opacity-0 transition-colors duration-100 group-hover/project:pointer-events-auto group-hover/project:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100'; -const IMAGE_SIDEBAR_ACTION_CLASS = 'motion-press flex min-h-9 w-full items-center justify-start 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 duration-100 hover:bg-surface-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-1'; - -function conversationTimestamp(conversation: DesignConversationSummary): number { - const timestamp = Date.parse(conversation.updatedAt); - return Number.isFinite(timestamp) ? timestamp : 0; -} - -function formatConversationTime(timestamp: number): string { - if (!timestamp) return ''; +function formatUpdatedAt(value: string): string { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return ''; const date = new Date(timestamp); const now = new Date(); if (date.toDateString() === now.toDateString()) { @@ -60,42 +44,20 @@ function formatConversationTime(timestamp: number): string { return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }); } -function compactConversationPreview(preview: string): string { - const characters = Array.from(preview); - return characters.length > CONVERSATION_PREVIEW_LIMIT - ? `${characters.slice(0, CONVERSATION_PREVIEW_LIMIT - 1).join('')}…` - : preview; -} - -function conversationPreview(conversation: DesignConversationSummary): string { - return conversation.latestMessagePreview?.trim() - || conversation.brief.summary.trim() - || conversation.title.trim() - || '新会话'; -} - export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSidebarProps) { const authenticated = useAuthStore((state) => state.isAuthenticated()); - const location = useLocation(); const navigate = useNavigate(); const status = useImageWorkspaceStore((state) => state.status); const bootstrap = useImageWorkspaceStore((state) => state.bootstrap); const activeWorkspaceId = useImageWorkspaceStore((state) => state.activeWorkspaceId); - const activeConversationId = useImageWorkspaceStore((state) => state.activeConversationId); const workspace = useImageWorkspaceStore((state) => state.workspace); - const creatingConversation = useImageWorkspaceStore((state) => state.creatingConversation); const deletingWorkspaceId = useImageWorkspaceStore((state) => state.deletingWorkspaceId); const workspaceError = useImageWorkspaceStore((state) => state.error); const load = useImageWorkspaceStore((state) => state.load); const createProject = useImageWorkspaceStore((state) => state.createProject); - const createConversation = useImageWorkspaceStore((state) => state.createConversation); const deleteProject = useImageWorkspaceStore((state) => state.deleteProject); const renameProject = useImageWorkspaceStore((state) => state.renameProject); const selectProject = useImageWorkspaceStore((state) => state.selectProject); - const selectConversation = useImageWorkspaceStore((state) => state.selectConversation); - const [projectsOpen, setProjectsOpen] = useState(true); - const [expandedProjectIds, setExpandedProjectIds] = useState>({}); - const [expandedConversationLists, setExpandedConversationLists] = useState>({}); const [dialog, setDialog] = useState(null); const [projectName, setProjectName] = useState(''); const [dialogError, setDialogError] = useState(null); @@ -108,15 +70,6 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba if (status === 'idle' || (status === 'auth-required' && authenticated)) void load(); }, [authenticated, load, status]); - useEffect(() => { - if (!activeWorkspaceId) return; - setExpandedProjectIds((current) => ( - current[activeWorkspaceId] === true - ? current - : { ...current, [activeWorkspaceId]: true } - )); - }, [activeWorkspaceId]); - useEffect(() => { const openCreateDialog = () => { setProjectName(''); @@ -127,6 +80,13 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba return () => window.removeEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, openCreateDialog); }, []); + const sortedProjects = useMemo( + () => [...(bootstrap?.workspaces ?? [])].sort( + (left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt), + ), + [bootstrap?.workspaces], + ); + const openCreateDialog = () => { setProjectName(''); setDialogError(null); @@ -139,46 +99,6 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba setDialog({ mode: 'rename', project }); }; - const openDeleteDialog = (project: DesignWorkspaceSummary) => { - setDeleteConfirmation(''); - setDeleteError(null); - setDeleteTarget(project); - }; - - const closeDialog = () => { - if (saving) return; - setDialog(null); - setDialogError(null); - }; - - const deletingProject = Boolean( - deleteTarget && deletingWorkspaceId === deleteTarget.workspaceId, - ); - const deleteNameMatches = Boolean( - deleteTarget && deleteConfirmation.trim() === deleteTarget.title, - ); - - const closeDeleteDialog = () => { - if (deletingProject) return; - setDeleteTarget(null); - setDeleteConfirmation(''); - setDeleteError(null); - }; - - const confirmDeleteProject = async () => { - if (!deleteTarget || !deleteNameMatches || deletingProject) return; - const target = deleteTarget; - setDeleteError(null); - try { - await deleteProject(target.workspaceId); - setDeleteTarget(null); - setDeleteConfirmation(''); - toast.success(`已删除项目「${target.title}」`); - } catch (error) { - setDeleteError(error instanceof Error ? error.message : String(error)); - } - }; - const saveProject = async () => { const title = projectName.trim(); if (!title) { @@ -190,479 +110,272 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba try { if (dialog?.mode === 'rename') { await renameProject(dialog.project.workspaceId, title); + toast.success('项目名称已更新'); } else { await createProject(title); + navigate('/image-canvas'); } setDialog(null); - navigate('/image-canvas'); } catch (error) { - setDialogError(error instanceof Error ? error.message : String(error)); + setDialogError(error instanceof Error ? error.message : '保存项目失败'); } finally { setSaving(false); } }; - const handleSelectProject = (workspaceId: string) => { - setExpandedProjectIds((current) => ({ ...current, [workspaceId]: true })); - void selectProject(workspaceId); - navigate('/image-canvas'); - }; - - const handleToggleProject = (workspaceId: string) => { - if (workspaceId !== activeWorkspaceId) { - handleSelectProject(workspaceId); + const confirmDelete = async () => { + if (!deleteTarget) return; + if (deleteConfirmation !== deleteTarget.title) { + setDeleteError('请输入完整项目名称'); return; } - setExpandedProjectIds((current) => ({ - ...current, - [workspaceId]: !(current[workspaceId] ?? true), - })); - }; - - const handleSelectConversation = (conversationId: string) => { - void selectConversation(conversationId); - navigate('/image-canvas'); - }; - - const handleCreateConversation = async () => { + setDeleteError(null); try { - await createConversation(); - navigate('/image-canvas'); + await deleteProject(deleteTarget.workspaceId); + setDeleteTarget(null); + setDeleteConfirmation(''); + toast.success('设计项目已删除'); } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); + setDeleteError(error instanceof Error ? error.message : '删除项目失败'); } }; - const activeProjectConversations = useMemo(() => { - if (!workspace || workspace.workspaceId !== activeWorkspaceId) return []; - return [...workspace.conversations].sort( - (left, right) => conversationTimestamp(right) - conversationTimestamp(left), - ); - }, [activeWorkspaceId, workspace]); + const chooseProject = (project: DesignWorkspaceSummary) => { + void selectProject(project.workspaceId); + navigate('/image-canvas'); + }; - const promptMuseumActive = location.pathname === '/image-prompts' - || location.pathname.startsWith('/image-prompts/'); + if (sidebarCollapsed) { + return ( +
+ + +
+ ); + } return ( -
- - - - -
+
+
+ - - {!sidebarCollapsed ? ( - - {status === 'idle' || status === 'loading' ? ( -
-
- - 正在读取设计项目 -
-
- ) : null} - - {status === 'unavailable' || status === 'error' || status === 'auth-required' ? ( -
-

AI 设计暂不可用

- {workspaceError ?

{workspaceError}

: null} - -
- ) : null} - - {status === 'ready' && bootstrap?.workspaces.length === 0 ? ( -
- 还没有设计项目 - 创建后,设计 Agent 会持续保留对话与生成任务。 -
- ) : null} - - {status === 'ready' ? bootstrap?.workspaces.map((project) => { - const active = project.workspaceId === activeWorkspaceId; - const projectExpanded = expandedProjectIds[project.workspaceId] ?? active; - const projectConversationsOpen = active && projectExpanded; - const conversations = active ? activeProjectConversations : []; - const visibleConversations = conversations.slice(0, VISIBLE_CONVERSATION_LIMIT); - const hiddenConversations = conversations.slice(VISIBLE_CONVERSATION_LIMIT); - const moreConversationsOpen = expandedConversationLists[project.workspaceId] - ?? hiddenConversations.some((conversation) => ( - conversation.conversationId === activeConversationId - )); - const renderConversation = (conversation: DesignConversationSummary) => { - const fullPreview = conversationPreview(conversation); - const selected = conversation.conversationId === activeConversationId; - return ( - - ); - }; - return ( -
-
- -
- - - - -
-
- -
- {workspace?.workspaceId !== project.workspaceId ? ( -
- - 正在读取历史会话 -
- ) : conversations.length === 0 ? ( - - ) : ( -
- {visibleConversations.map(renderConversation)} - {hiddenConversations.length > 0 ? ( -
- - - {hiddenConversations.map(renderConversation)} - -
- ) : null} -
- )} -
-
-
- ); - }) : null} -
- ) : null}
- {dialog ? ( - { - if (!open) closeDialog(); - }} - > - { - if (saving) event.preventDefault(); - }} +
+ 设计项目 + {(status === 'error' || status === 'unavailable') && ( + - + + + )} +
-
{ - event.preventDefault(); - void saveProject(); - }} +
+ {status === 'loading' && ( +
+ {[0, 1, 2].map((item) => ( +
+ ))} +
+ )} + + {(status === 'error' || status === 'unavailable') && ( +
+ {workspaceError ?? 'AI 设计暂时不可用'} +
+ )} + + {status === 'ready' && sortedProjects.length === 0 && ( + + )} + + {sortedProjects.map((project) => { + const active = project.workspaceId === activeWorkspaceId; + const pending = project.workspaceId === activeWorkspaceId && !workspace; + return ( +
- - { - setProjectName(event.target.value); - setDialogError(null); - }} - autoFocus - 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" - /> - {dialogError ?

{dialogError}

: null} -
- - -
- - -
- ) : null} - - {deleteTarget ? ( - { - if (!open) closeDeleteDialog(); - }} - > - { - if (deletingProject) event.preventDefault(); - }} - onEscapeKeyDown={(event) => { - if (deletingProject) event.preventDefault(); - }} - > - -
- 删除项目 - - 删除后,项目“{deleteTarget.title}”及其会话、任务、参考图和生成作品会从账户中隐藏且无法访问。未提交的任务会被取消并释放预留积分;已提交或运行中的任务会继续后台结算,但结果对你不可见。 - -
-
- -
- - { - setDeleteConfirmation(event.target.value); - setDeleteError(null); - }} - autoFocus - disabled={deletingProject} - 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-destructive/20 disabled:cursor-not-allowed disabled:opacity-60" - /> - {deleteConfirmation && !deleteNameMatches ? ( -

项目名称必须完全匹配。

- ) : null} - {deleteError ? ( -

- {deleteError} -

- ) : null} -
- - + + +
-
-
- ) : null} + ); + })} +
+ + !open && !saving && setDialog(null)}> + + + {dialog?.mode === 'rename' ? '重命名设计项目' : '新建设计项目'} + + 创建后会立即生成一份空白设计表单,可通过对话或直接编辑完善。 + + +
+ + setProjectName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !saving) void saveProject(); + }} + /> + {dialogError &&

{dialogError}

} +
+
+ + +
+
+
+ + { + if (!open && !deletingWorkspaceId) setDeleteTarget(null); + }} + > + + + 删除设计项目 + + 这会隐藏项目、设计表单、任务与资产。已另存到电脑的文件不受影响。 + + +
+ + setDeleteConfirmation(event.target.value)} + /> + {deleteError &&

{deleteError}

} +
+
+ + +
+
+
); } diff --git a/src/lib/image-workspace.ts b/src/lib/image-workspace.ts index fad93a2..05d71d8 100644 --- a/src/lib/image-workspace.ts +++ b/src/lib/image-workspace.ts @@ -12,11 +12,9 @@ import { designAssetDownloadPath, type DesignAsset, type DesignAssetSaveResult, - type DesignConversation, + type DesignCommandInput, + type DesignCommandResult, type DesignDeleteWorkspaceResult, - type DesignGenerationParameters, - type DesignGenerationQuote, - type DesignGenerationTask, type DesignWorkspace, type DesignWorkspaceBootstrap, } from '../../shared/image-workspace'; @@ -49,12 +47,8 @@ function createClientId(prefix: string): string { return `${prefix}-${id}`; } -export function createImageWorkspaceTurnId(): string { - return createClientId('turn'); -} - -export function createImageWorkspaceConversationId(): string { - return createClientId('conversation'); +export function createImageWorkspaceOperationId(): string { + return createClientId('design-operation'); } function getErrorStatus(error: unknown): number { @@ -105,10 +99,7 @@ export function createImageWorkspaceProject( ): Promise { return requestData(`${IMAGE_WORKSPACE_API_PATH}/workspaces`, { method: 'POST', - body: JSON.stringify({ - clientWorkspaceId, - title: title.trim(), - }), + body: JSON.stringify({ clientWorkspaceId, title: title.trim() }), }); } @@ -118,10 +109,7 @@ export function renameImageWorkspaceProject( ): Promise { return requestData( `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}`, - { - method: 'PATCH', - body: JSON.stringify({ title: title.trim() }), - }, + { method: 'PATCH', body: JSON.stringify({ title: title.trim() }) }, ); } @@ -140,115 +128,26 @@ export function fetchImageWorkspaceProject(workspaceId: string): Promise { +export function submitImageWorkspaceCommand( + command: DesignCommandInput, +): Promise { + const { workspaceId, ...body } = command; return requestData( - `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations`, - { - method: 'POST', - body: JSON.stringify({ - clientConversationId, - title: title.trim(), - }), - }, + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/commands`, + { method: 'POST', body: JSON.stringify(body) }, ); } -export function fetchImageWorkspaceConversation( +export async function openImageWorkspaceEvents( workspaceId: string, - conversationId: string, - before?: string, -): Promise { - const query = before ? `?before=${encodeURIComponent(before)}` : ''; - return requestData( - `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}${query}`, - ); -} - -export function sendImageWorkspaceMessage( - workspaceId: string, - conversationId: string, - expectedTurnRevision: number, - message: string, - clientTurnId = createImageWorkspaceTurnId(), - attachmentAssetIds: string[] = [], -): Promise { - return requestData( - `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}/messages`, - { - method: 'POST', - body: JSON.stringify({ - clientTurnId, - expectedTurnRevision, - message: message.trim(), - attachmentAssetIds, - }), - }, - ); -} - -export function confirmImageWorkspaceGeneration( - workspaceId: string, - conversationId: string, - expectedTurnRevision: number, - quoteId: string, - clientTurnId = createImageWorkspaceTurnId(), - finalPrompt?: string, - generationParameters?: DesignGenerationParameters, -): Promise { - return requestData( - `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}/quotes/${encodeURIComponent(quoteId)}/confirm`, - { - method: 'POST', - body: JSON.stringify({ - clientTurnId, - expectedTurnRevision, - ...(finalPrompt === undefined ? {} : { finalPrompt }), - ...(generationParameters === undefined ? {} : { generationParameters }), - }), - }, - ); -} - -export function updateImageWorkspaceGenerationQuote( - workspaceId: string, - quoteId: string, - finalPrompt: string, - generationParameters: DesignGenerationParameters, -): Promise { - return requestData( - `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/generation-quotes/${encodeURIComponent(quoteId)}`, - { - method: 'PATCH', - body: JSON.stringify({ - finalPrompt, - model: generationParameters.model, - resolution: generationParameters.resolution, - aspectRatio: generationParameters.aspectRatio, - durationSeconds: generationParameters.durationSeconds, - }), - }, - ); -} - -export function fetchImageWorkspaceTasks( - workspaceId: string, -): Promise { - return requestData( - `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/tasks`, - ); -} - -export async function openImageWorkspaceTaskEvents( - workspaceId: string, - conversationId: string, + sessionId: string, + afterEventId?: string, ): Promise { await ensureHostApiToken(); + const query = new URLSearchParams({ sessionId }); + if (afterEventId) query.set('afterEventId', afterEventId); return createHostEventSource( - `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}/events`, + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/events?${query}`, ); } @@ -289,11 +188,7 @@ export function saveImageWorkspaceAsset(asset: DesignAsset): Promise { diff --git a/src/pages/ImageCanvas/DesignConversationPane.tsx b/src/pages/ImageCanvas/DesignConversationPane.tsx new file mode 100644 index 0000000..e33f593 --- /dev/null +++ b/src/pages/ImageCanvas/DesignConversationPane.tsx @@ -0,0 +1,206 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { Check, Loader2, MessageSquareText, Send, Sparkles, X } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; +import { useImageWorkspaceStore } from '@/stores/image-workspace'; +import type { DesignWorkspace } from '../../../shared/image-workspace'; + +const STARTER_MESSAGES = [ + '帮我做一张新品发布主视觉,先问我最关键的问题', + '我有一个模糊想法,请帮我整理成专业的图片设计方案', + '我要做一段产品短视频,请先规划叙事和分镜', +]; + +export function DesignConversationPane({ workspace }: { workspace: DesignWorkspace }) { + const chatDraft = useImageWorkspaceStore((state) => state.chatDraft); + const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft); + const sendChat = useImageWorkspaceStore((state) => state.sendChat); + const resolveDecisionPrompt = useImageWorkspaceStore((state) => state.resolveDecisionPrompt); + const assistantStreams = useImageWorkspaceStore((state) => state.assistantStreams); + const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations); + const scrollAnchorRef = useRef(null); + const streams = useMemo( + () => Object.entries(assistantStreams).filter(([, text]) => text.trim()), + [assistantStreams], + ); + const submittingChat = Object.values(pendingOperations).some( + (operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input', + ); + + useEffect(() => { + const scrollAnchor = scrollAnchorRef.current; + if (typeof scrollAnchor?.scrollIntoView === 'function') { + scrollAnchor.scrollIntoView({ block: 'end' }); + } + }, [streams, workspace.form.decisionPrompts.length, workspace.turns.length]); + + const submit = () => { + if (!chatDraft.trim() || submittingChat) return; + void sendChat().catch((error) => { + toast.error(error instanceof Error ? error.message : '消息发送失败'); + }); + }; + + return ( +
+
+
+ +

与设计 Agent 对话

+
+

+ 你说的每句话都会先进入设计规格,再由服务端编译成专业生成方案。 +

+
+ +
+ {workspace.turns.length === 0 && streams.length === 0 && ( +
+
+ +
+

从自然语言开始

+

+ 不需要一次把需求说完整。Agent 会把信息写入右侧表单,并把关键取舍留给你确认。 +

+
+ {STARTER_MESSAGES.map((message) => ( + + ))} +
+
+ )} + +
+ {workspace.turns.map((turn) => ( +
+ {turn.userMessage && ( +
+ {turn.userMessage} +
+ )} + {turn.assistantMessage && ( +
+ {turn.assistantMessage} +
+ )} +
+ ))} + + {streams.map(([operationId, text]) => ( +
+ {text} + +
+ ))} + + {workspace.form.decisionPrompts.map((prompt) => ( +
+
+
+ +
+
+

{prompt.title}

+

{prompt.body}

+
+
+
+ {prompt.options.map((option) => ( + + ))} +
+ +
+ ))} +
+
+
+ +