# 巨壳拆分 · 多窗口任务派发

> 把下面的「公共前言」粘到每个 AI 窗口的第一条消息，再粘一个「任务 X」。一个窗口做一个任务。
> 已完成（main 上）：B Scene3DFullscreen 4588→3860 / A NodeParameterControls 639→412 / C generationCanvasStore 1122→871 / D（首切片）GenerationCanvas 1111→992。
> 方法论参考：`docs/plan/2026-06-08-巨壳拆分-B-Scene3D-A-NodeParameterControls.md`。

---

## 公共前言（每个窗口先粘这段）

```
你在 Nomi 项目（/Users/aoqimin/Desktop/Nomi，分支 main）做一次「巨壳文件拆分」重构。先读 CLAUDE.md 与 docs/coding-standards.md 了解工程纪律，必读以下硬约束：

1. 纯结构搬运、**零行为变更**。把内部的 常量/类型/纯函数/hook/子组件 搬到形制相同的兄弟文件，原文件 import 回来。函数体/JSX/依赖数组逐字节保留，不许"顺手优化"（包括不许补 useEffect 依赖、不许改逻辑）。
2. 搬之前先 `git show HEAD:<目标文件>` 留底，搬完用它逐行核对等价。
3. 破循环依赖：新文件之间单向依赖；类型若引用原文件的大 State 类型，用结构性 Pick 类型（如 `{ persistRevision: number }`）避免反向 import。
4. 警惕重名：搬出的符号若与项目别处同名但语义不同（grep 确认），必须保留本地语义/改名，绝不误 import 另一个。
5. 验证门（push 前全过）：`pnpm run check:filesize` → `pnpm run lint:ci`（max-warnings=98，不许新增 warning）→ `pnpm run typecheck` → `pnpm run test`（不回归）→ `pnpm run build`。搬完若原文件仍 >800 行，把 `scripts/check-file-sizes.mjs` 里**该文件那一行**的基线下调到实测行数（只改你这一行，别动别的行）。
6. 协调（多窗口并行）：push 前先 `git pull --rebase origin main`，因为有别的窗口也在 push。只 `git add` 你这个任务涉及的文件 + check-file-sizes.mjs 你那一行。commit message 中文，说清做了什么+为什么+验证结果，结尾挂 Co-Authored-By 行。
7. 该文件零单测覆盖时，CI 绿证明不了交互没坏：补一次真机走查（tests/ux/ui-driver.mjs + tests/ux/ui.mjs，先 ps 清掉僵尸 Electron，确认你抽出的组件/逻辑在真机仍工作）再报完成。
8. 删旧：原文件里被搬走的定义和因此变 unused 的 import 必须删干净（lint 会报）。
```

---

## 任务 1 — GenerationCanvas 出巨壳线（viewport hook + 设置弹层）

```
目标文件：src/workbench/generationCanvas/components/GenerationCanvas.tsx（当前 ~992 行，要降到 <800 出巨壳）。
它只被 NomiStudioApp.tsx 以默认导入使用，内部抽取对外无影响。

抽两块（都在巨型组件函数体内部，**这是本批最缠绕的一个，务必逐行核对 + R13**）：
1. components/useCanvasViewport.ts —— pan/zoom/虚拟化：viewport state、zoom/offset、分类视口 save/restore effect、isPanning、panStartRef/offsetFrameRef/pendingOffsetRef、stageRef、stageSize + ResizeObserver effect、visibleNodesForRender（虚拟化 AABB）、handleWheel、getCanvasPointFromClientPoint、fitView/zoomAtStagePoint 等。返回组件 JSX 与其它 handler 需要的值（含 offsetRef/zoomRef——注意 drag-connection effect 也读 offsetRef，搬走后要让它仍能读到同一个 ref）。
2. components/CanvasSettingsDialog.tsx —— settingsOpen/apiKey/baseUrl/settingsSaved 的设置弹层 JSX + 保存 handler（writeProviderSettings + desktop.modelCatalog.upsertVendorApiKey 调用），改 props 驱动。providerSettings 已在 ../services/providerSettings.ts。

最大风险：offsetRef/zoomRef 是跨多个 effect 共享的可变 ref；hook 返回它们，消费方（drag-connection effect、edge 层的 getCanvasPointFromClientPoint prop）要继续读同一个 ref 实例。hook 必须在所有条件 return 之前无条件调用（Rules of Hooks）。
R13：真机打开生成画布，验证 平移/滚轮缩放/适应视图/虚拟化（多节点）/打开设置弹层存 key 都正常。
```

---

## 任务 2 — BaseGenerationNode 拆分

```
目标文件：src/workbench/generationCanvas/nodes/BaseGenerationNode.tsx（1399 行，巨壳）。每节点渲染一次、**性能敏感**（文件里有 v0.7.2 perf 注释），零单测。

按下列拆（参考审计，自己读文件核实边界）：
- nodes/hooks/useNodeDragMove.ts —— 拖拽移动：drag refs + rAF flush 调度（约 261-343 区）。
- nodes/hooks/useNodeResize.ts —— 缩放：handlePointerMove 里的 resize 分支（含宽高比约束数学）+ handleResizePointerDown。
- nodes/hooks/usePanoramaCapture.ts —— 全景上传/截图（handlePanoramaFileChange/handlePanoramaScreenshot + 相关 refs）。
- nodes/render/MediaNodeBody.tsx / PanoramaNodeBody.tsx / VideoNodeBody.tsx —— 按节点类型拆 body 渲染（参考已有 CharacterCardNode/SceneCardNode 等同目录模式）。
- 可选纯工具：nodes/nodeSizing.ts（尺寸计算）、nodes/nodeTimelineDrop.ts（findTimelineDropTarget 坐标）。

风险：拖拽/缩放是 refs + 业务 math + 事件管线的密集结，把 store 订阅拆进小 hook 能直接减重渲染面（性能正收益），但务必逐字节保留 rAF 调度时机与指针事件顺序。
R13：真机拖动节点、八向缩放手柄改尺寸、全景节点上传/截图，确认行为与重渲染都正常。
```

---

## 任务 3 — electron/runtime.ts 拆分（运行时关键，要真机 smoke）

```
目标文件：electron/runtime.ts（2516 行，承担 8 类正交职责）。它是 electron 主进程，被 electron/main.ts 大量 import。

抽三块（catalog/ 与 export/ 子目录已存在）：
- electron/ai/agentChatV2.ts —— makeAgentTool / buildCanvasToolsForV2 / buildDocumentToolsForV2 / buildToolsForSkill / clearAgentChatV2History / runAgentChatV2 + history map（约 2257-2516 区）。
- electron/export/exportJobs.ts —— startExportJob / writeExportTempInput / finishExportTempInput / cancelExportJob / subscribeExportJobEvents / startTimelineMp4Export / showExportInFolder / tryBuildFiltergraphExport + helpers（约 344-705 区）。
- electron/catalog/catalogStore.ts —— readCatalog/writeCatalog/migrateCatalogForward/normalizeLegacyMappings + 各 list/upsert/delete/commit/test/health（约 708-1582 区）。

**关键（保持与 main.ts 解耦）**：被搬走的、main.ts 仍 import 的符号（尤其 runAgentChatV2、各 catalog CRUD），在 runtime.ts 里用 `export { X } from './ai/agentChatV2'` 等再导出，保证 main.ts 的现有 import 路径不破。这样本任务与任务 4 可独立并行。
真机 smoke：渲染层抓不到主进程 HTTP；用 tests/ux 驱动跑一次真实生成/导出/模型接入链路（或至少 app 启动 + 打开模型接入面板 + 触发一次导出），确认 IPC/catalog/export 仍工作。
```

---

## 任务 4 — electron/main.ts 拆分

```
目标文件：electron/main.ts（775 行）。问题是 IPC 注册里嵌着真业务。

抽三块：
- electron/ai/onboarding/onboardingIpc.ts —— probeOneProtocol（多协议探测启发式）+ OnboardingSession + registerOnboardingIpc（start/cancel/manual-commit/test-connection/list-models）（约 421-727 区）。
- electron/ai/agentChatV2Ipc.ts —— AgentChatV2Session + agentChatV2Sessions map + sendChatV2Event + registerAgentChatV2Ipc（约 309-419 区）。
- electron/export/exportJobIpc.ts —— exportJobEventSubscriptions + registerExportJobEventForwarding + registerIpc 里 nomi:exports:* 那几条转发（约 268-307 区）。

抽完 main.ts 应 ≈300 行（引导 + window + 薄 IPC 表）。
注意：若任务 3 尚未合并，你从 runtime.ts import 的符号路径不变（任务 3 用再导出保证）。R13：app 正常启动、新建项目、打开模型接入、触发导出菜单。
```

---

## 任务 5 — NodeParameterControls 的 asset-slot 抽取（A 的遗留高风险件）

```
目标文件：src/workbench/generationCanvas/nodes/NodeParameterControls.tsx（当前 412 行；本任务把 asset-slot 逻辑抽成 hook）。零渲染测试。
抽：nodes/useNodeAssetSlots.ts —— 把 setArrayValue/handleArrayAdd/handleArrayRemove/handleArrayUpload/handleSourceVideoUpload/handleSlotAssignment/setSingleFrameUrlMeta/handleSlotUpload + 派生（modelImageUrlSlots/imageUrlSlots/arraySlots/assetSlots/assetValuesByKey）+ handleAssetPick/handleAssetUpload/handleReorder/handleBrowseAll/handleAssetRemove + 上传 state（openSlotKey/uploadingSlotKey/uploadingArrayKey/uploadError）整组搬进 hook，返回组件 JSX 需要的 slots/values/handlers/上传态。

风险：这 4 个 state 与 handleModeSwitch（setOpenSlotKey）等多处耦合——hook 要返回 setOpenSlotKey 给留在组件里的 handleModeSwitch 用。先想清边界再搬（P5）。
R13：真机打开一个图片/视频节点，挂参考图（连边 + 上传 + 数组重排）、改参数、切模式，确认参考槽行为不变。
```

---

## 任务 6 — 中优先「真该拆」三件（各自独立，可拆成 3 个窗口）

```
三个独立文件，各做各的（互不相干，可并行）：
A) src/workbench/project/projectRepository.ts（602）——抽 projectStorage.ts（localStorage 原语 + ProjectStorageQuotaError）/ projectBackup.ts（备份索引+驱逐）/ projectNormalize.ts（summary/record/payload 归一化 + seedDocFromMarkdown + extractCanvasThumbnailUrls）；主文件留 CRUD 编排 + desktop/local 路由。
B) src/config/useModelOptions.ts（498，文件名像 hook 却塞 8 类）——抽 modelCatalogCache.ts（缓存 + MODEL_REFRESH_EVENT + notify/invalidate + getCatalogModelOptions/Health + preload）/ modelOptionResolvers.ts（trim*/inferImageModelVendor/findModelOptionByIdentifier/resolveExecutableImageModel*）/ modelCatalogStatus.ts（ModelCatalogStatus + deriveModelCatalogStatus + normalizeCatalogLoadError）/ modelOptionMappers.ts（toCatalogModelPricing/Options）；主文件只留 useModelOptionsState/useModelOptions 两个 hook。
C) src/workbench/generationCanvas/nodes/controls/parameterControlModel.ts（585）——抽 imageUrlSlots.ts（inferImageUrlGroup/buildImageUrlSlots/edgeModeForGroup/getEdgeSourceForSlot/getSlotNodeRef/getSlotThumbUrl/imageCatalogReferenceSlot）/ controlMetaAliases.ts（alias 表 + readMeta/readStringArray/readDurationOptions/readDefaultParam + key 工具）/ catalogControls.ts（image/video catalog 控件聚合器）；主文件留 buildDynamicControls/defaultPatchFor*/buildModelControls。
这三个偏纯逻辑/有相邻单测（如 archetypeMeta.test、useModelOptions 相关），靠 typecheck + test 多能兜住；仍按公共前言 git show 核对。
```

---

## 任务 7 — 中优先「切一两块」（各自独立）

```
四个独立文件，每个只切最肥的一两块即可（别强拆）：
A) electron/ai/onboarding/specExtractors.ts（645）——抽 extractors/embeddedDigest.ts（extractEmbeddedParameterData + DIGEST_KEYWORDS）+ extractors/dehydratedStore.ts（extractDehydratedParameters 全套）。
B) electron/ai/onboarding/tools.ts（635）——把最胖的 execute_test_curl、fetch_raw_docs 抽成 tools/executeTestCurl.ts / tools/fetchRawDocs.ts，由 buildOnboardingTools 装配；诊断 helper 抽 tools/diagnostics.ts。
C) src/workbench/generationCanvas/runner/catalogTaskActions.ts（506）——抽 catalogTaskResolve.ts（节点→vendor/model/kind 选择）+ catalogTaskResultParse.ts（raw/asset/failure/provenance 解析）。有 catalogTaskActions.test 兜底。
D) src/api/desktopClient.ts（496）——抽 desktopAgentsChatStream.ts（openDesktopAgentsChatStream + chatV2 事件适配 + 相关类型）。其余多是一行薄壳，不必硬拆。
注：modelCatalogMeta.ts（731）是同质解析器水平展开，**不拆**（可读性优先）。
```

---

## 协调规则（贴给你自己看）

- 这些任务**文件互不重叠**，唯一公共文件是 `scripts/check-file-sizes.mjs`（每个只改自己那一行基线，git 基本能自动合并）。
- 都直接 push main：每个窗口 push 前先 `git pull --rebase origin main`。冲突基本只会出现在 check-file-sizes.mjs 的相邻行，手动留两行即可。
- 风险/建议并行批次：任务 6、7（低风险、多有单测）可放心同时跑多个窗口；任务 1/2/3 高风险（零测试/运行时关键），建议一个窗口一个、做完 R13 再 push，别和高风险任务挤同一时段。
- 任务 3 与 4 有耦合：任务 3 用「再导出」保证 main.ts 不破后，两者可独立；若不放心，先合任务 3 再开任务 4。
