Files
CherryHQ-cherry-studio/v2-refactor-temp/docs/file-manager/migration-plan.md
fullex 53a3577389 refactor(renderer): flatten src/renderer/src to src/renderer
Move all renderer source from src/renderer/src/* up one level to
src/renderer/*, removing the redundant nested src directory.

- Update path aliases (@renderer, @types, @logger, @data) and TanStack
  Router paths in electron.vite.config.ts; update tsconfig.{json,web,node}
  path mappings and include globs.
- Fix Vite root-relative script paths in the 8 renderer HTML entries.
- Update cross-process relative imports in main/preload (language,
  apiServer models, preload index) to drop the /src segment.
- Switch renderer test imports of the logger mock to the @test-mocks alias.
- Update hardcoded renderer paths in scripts and their fixtures, lint
  configs (eslint/oxlint/biome), CODEOWNERS, docs, and the data-classify tool.
- Convert deep (../../+) relative imports within the renderer to the
  @renderer alias (69 files, 108 imports); keep single-level relatives.
- Fix doc links broken by the move and correct one pre-existing broken
  link in naming-conventions.md.
2026-05-28 21:40:20 -07:00

171 KiB
Raw Permalink Blame History

File Manager Migration Plan

本文档覆盖:把旧 FileMetadata / FileStorage 栈迁移到 v2 类型系统的具体执行计划

框架v2 把旧 FileMetadata 同时承担的两个角色DB 行 + 通用文件描述符)显式拆分

  • 持久化角色FileEntrysrc/shared/data/types/file/fileEntry.ts
  • 描述符角色FileInfosrc/shared/file/types/info.ts
  • 跨边界引用FileHandlesrc/shared/file/types/handle.ts

本文档以字段级退役(字段归 FileEntry / FileInfo / 彻底删除)和消费域切换(消费者按 P/I/A 桶归属分流)两条主线组织。

不在本文档范围

本文档与 RFC 的关系RFC §10迁移策略和 §11分阶段实施计划描述数据层迁移Dexie → SQLite 的一次性数据搬运)与总体阶段划分。本文档深入到字段级 / 消费者级的具体落地动作,是 RFC 的展开。


1. 全局结构

1.1 迁移的两条主线

主线 含义 本文档对应章节
字段级退役 FileMetadata 的每个字段"归到 FileEntry / 归到 FileInfo / 彻底删除"的决策与执行 §2
消费域切换 按业务域messages / knowledge / painting / ...)把 renderer 侧旧 API 调用换到 v2消费者先分 P/I/A 桶,再确定迁移到 FileEntry / FileInfo / 拆签名 §3

这两条可以并行推进,字段退役为域切换扫清障碍(减少 shim 期的适配面积)。

1.2 FileMetadata 角色拆分与桶归属

1.2.1 叙事校正:不是"替换",是"完成那次 refactor"

src/shared/data/types/file/file.ts:1-4 的类型注释写着:

/**
 * --------------------------------------------------------------------------
 * ⚠️ NOTICE: this type need be refactored after FileSystem is designed
 * --------------------------------------------------------------------------
 */

FileMetadata 从诞生就同时承担两种角色——DB 行Dexie files 表、message_block.file JSON通用文件描述符OCR 输入、TokenService 入参、UI 渲染字段源。v2 并不是"用 FileEntry 替换 FileMetadata",而是把两个角色显式拆开

FileMetadata 的角色 v2 对应类型 说明
DB 行 / 持久化身份 FileEntry idorigindeletedAt;有 lifecycleZod brand 强制走 sanctioned 生产路径
磁盘描述符 / 临时传参 FileInfo pathmodifiedAtlive view任意构造
跨边界引用(两者通用) FileHandle tagged unionIPC 边界首选签名

这决定了字段的归宿不是统一的"搬到 FileEntry"——需要逐字段判断它属于哪个角色(见 §2

1.2.2 消费者三桶划分P / I / A

审计(filemetadata-consumer-audit.md)按消费模式把 96 个 FileMetadata 消费者分三桶:

使用模式 迁移目标 代表消费者
P 持久化 FileMetadata 存进 Dexie / message_block / knowledge_item JSON读取时期望完整 DB 行 → FileEntry(或其中的 FileEntryId databases/index.ts Dexie 表、KnowledgeMigratorImageMessageBlock.file
I 描述符 只用 path / name / size / ext / type 驱动一次处理,不持久化任何身份 → FileInfo OCRSupportedOcrFile、TesseractService、TokenService、isSupportedFile
A 两栖 同一处既持久化又 pass-through或者签名要完整 FileMetadata 但实际只用子集("接口说谎" → 拆签名:持久化路径接 FileEntry处理路径接 FileInfo services/FileManager.ts:addFile/uploadFileKnowledgeService preprocessing、InputbarCore:458

关键规则

  • 桶 P 消费者迁移时直接目标 FileEntry(或对只需要 id 的场合用 FileEntryId),按业务域切分 PR
  • 桶 I 消费者迁移时直接目标 FileInfo不经过 FileEntry 中间层——它们从未需要身份shim 期也不需要适配
  • 桶 A 消费者是真正的工作量:要逐个把"接口说谎"拆成两段——持久化动作显式调 createInternalEntry / ensureExternalEntry,处理动作接 FileInfo
  • 升格只有一条路FileInfo → FileEntry 必须走 FileManager 的 sanctioned 生产入口,不提供隐式 converter

1.2.3 Shim 期的 scope 限定

原先打算的"双向 shimFileEntry ↔ FileMetadata"被这个拆分压缩了适用范围:

  • 桶 I shim:不需要——消费者从 FileMetadata 改吃 FileInfo 只是字段瘦身(丢 id / count / origin_name 这类身份字段),没有跨系统 boundary直接改签名即可
  • 桶 P shim:需要,但只在桶 P 迁移未完成的域里保留,把 v2 FileEntry 投影回旧 FileMetadata 形状给尚未迁移的持久化消费者;反向(旧 FileMetadata 读出当 FileEntry 用)仅在兼容旧 Dexie 数据窗口期需要
  • 桶 A shim不提供——A 桶必须拆签名解决,不能靠 shim 糊弄过去;否则两栖代码永远不会真正清理

See §4 for the shim function specifications.

1.3 调研依赖图

  filemetadata-consumer-audit.md  ──引用──▶  migration-plan.md
  (完整现状快照 96 files + P/I/A 桶标签)     (落地计划)
        │
        └──  每个字段 / 每个域都在 audit 里有具体 file:line
             本文档只重复"结论 + 动作",不重复原始引用清单

对每个字段 / 域的迁移条目,先到 audit 查清楚现状,再写入本文档。写入时只保留动作相关的少量引用(具体行号),深度引用留在 audit。


2. 字段级退役计划

归宿规则(按 §1.2.1 的角色拆分):

  • 身份 / 持久化相关字段 → FileEntry(可能伴随重命名或格式变化)
  • 磁盘描述符相关字段 → FileInfo
  • 业务特化 / 死字段 / 本来就不该在文件对象上的字段 → 彻底删除归到各自合适的地方upload 调用参数、file_ref 聚合、TokenService 内部 cache 等)

本章每个字段按"属于哪个角色 / 如何退役"展开。

2.1 字段退役总览

下表给出每个旧 FileMetadata 字段的归属决策(按 §1.2.1 的角色拆分);详细迁移方案分别展开。

旧字段 角色 v2 归属 迁移方案 状态
purpose? 彻底删除 上移到 upload 调用参数(未来 file_upload.metadata §2.2 📋 计划完成
count 彻底删除 file_ref 表按 source 聚合(身份语义变化,不再是文件对象字段) §2.3 📋 计划完成
tokens? 彻底删除 死字段,纯删除 §2.4 📋 计划完成
type FileInfo-only FileInfo.typeext 派生,默认)+ ops.getMetadataPhysicalFileMetadata.type(按需);FileEntry 不存此列 §2.5 📋 计划完成
path FileInfo-only FileInfo.pathunmanaged 身份managed 侧由 resolvePhysicalPath(entry) 动态派生,FileEntrypath §2.6 📋 计划完成
name (存储名) 彻底删除 name = id + ext 的冗余物理命名约定废弃storage path 由 resolvePhysicalPath(entry) 派生 {id}.{ext} §2.7 📋 计划完成
origin_name FileEntry + FileInfo 拆分并重命名:FileEntry.name(不含扩展名)+ FileEntry.ext(不含前导点);FileInfo.name / FileInfo.ext 从 basename 派生 §2.7 📋 计划完成
created_at FileEntry-only ISO string → FileEntry.createdAt: numberms epochdayjs 天然兼容;FileInfomodifiedAtmtime不持有 entry 级别的创建时间 §2.8 📋 计划完成
id (UUID v4) FileEntry-only 保留原 v4 id新 entry 走 v7Schema 放宽为 z.uuid()FileInfo 无 id 字段 §2.9 📋 计划完成
size(共享) FileEntry + FileInfo 字段名不变,但语义分裂:FileEntry.size 是注册时快照external 可能 driftFileInfo.sizefs.stat 实时 N/A

图例📋 = 调研完成且方案清晰;🔍 = 仅列点,待单独深入调研。

2.2 purpose 字段

FileMetadata.purpose?: OpenAI.FilePurpose 在旧模型里挂在"文件"上,但实际上它是一次上传调用的参数不是文件本身的属性。v2 FileEntry 不保留此字段。

现状调研

生产方0 个稳定 setter

  • renderer FileManager.ts / main FileStorage.ts 创建 FileMetadata 时不写入 purpose——数据库中 99% 实例该字段为 undefined
  • 唯一 settersrc/renderer/aiCore/prepareParams/fileProcessor.ts:128-132,对 qwen-long / qwen-doc 模型 spread 一个临时副本设 purpose: 'file-extract',用完即扔,不回写 DB

消费方2 个)

  • src/main/services/remotefile/OpenAIService.ts:35purpose: file.purpose || 'assistants' 传给 client.files.create
  • src/renderer/aiCore/prepareParams/fileProcessor.ts:141-143 — 校验远端已上传文件的 purpose 和本地 file.purpose 是否一致(不一致抛错重传)

Schema 化石

  • src/shared/data/types/knowledge.ts:53purpose 塞进 KnowledgeFileItem,但 Knowledge 业务代码 零消费

迁移目标

purpose 从"文件属性"改为"upload 调用参数",符合以下两条原则:

  1. FileEntry 只描述文件本身,不绑定某一次 upload 行为
  2. 未来 file_upload 表记录"当初用什么 purpose 上传",而不是文件永久持有一个模糊的 purpose

迁移步骤

# 文件 改动
1 src/renderer/aiCore/prepareParams/fileProcessor.ts:121-132 不再 spread file 副本,改为提取 purpose 为独立变量(由 model 决定)传入 upload/retrieve 调用
2 src/main/services/remotefile/OpenAIService.ts:25 uploadFile(file, options?: { purpose?: OpenAI.FilePurpose });内部 options?.purpose ?? 'assistants'
3 对应 preload bridgewindow.api.fileService.upload 签名加 options?.purpose,同步转发
4 src/renderer/aiCore/prepareParams/fileProcessor.ts:141 cache mismatch 比较:remoteFile.purpose !== purpose(局部变量,非 file.purpose
5 src/shared/data/types/knowledge.ts:53 KnowledgeFileItem schema 删除 purpose 字段
6 src/shared/data/types/file/file.ts:28 + src/renderer/types/file.ts:127 FileMetadata.purpose? 字段移除

执行时机

可作为独立小 PR在 v2 文件管理重构之前或之后均可

  • 之前(推荐):提前把 purpose 从 FileMetadata 剥离v2 FileEntry 天然不需要为此字段做任何决策qwen-long 的 'file-extract' 行为在 upload 调用点清晰体现
  • 之后:作为 Cleanup Batch 的一部分。缺点Batch A-E consumer migration 期间 FileEntry → FileMetadata 的适配层还要补造一个 purpose: undefined,增加噪声

PR 命名建议refactor(file): move FilePurpose from FileMetadata to upload call sites

未来 file_upload 表对 purpose 的处理

当 AI SDK 稳定后引入 file_upload 表(见 file-manager-architecture.md §9purpose 可作为 metadata JSON 字段的一部分记录:

{
  "file_entry_id": "...",
  "provider": "openai",
  "remote_id": "file-abc123",
  "content_version": "xxh128:...",
  "metadata": { "purpose": "file-extract" } // per-upload, not per-file
}

这样"同一文件用不同 purpose 上传到同一 provider"的场景能天然区分(甚至可以放宽 UNIQUE 约束改为 UNIQUE(file_entry_id, provider, purpose)),比旧模型单一字段准确得多。

两个消费方的 silent failure 模式

purpose 字段一旦在 v1 ↔ v2 投影层被丢弃,影响是双重静默的,外部观察不到错误:

  1. OpenAIService.ts:35purpose: file.purpose || 'assistants'

    • file.purpose 为 undefined 时静默回退到 'assistants'
    • 调用 qwen-long / qwen-doc 的文件被当成 assistant 文件上传OpenAI API 不会报错
    • 后果:远端解析方式与本地预期不一致,模型回答可能无声地降级
  2. fileProcessor.ts:141-143remoteFile.purpose !== file.purpose 校验

    • file.purpose === undefined ≠ 任何非空远端 purpose → 永远 mismatch → 抛 "File purpose mismatch" 重传
    • 后果OpenAI 文件 de-dup 缓存失效,每次对话都重新上传同一个文件,浪费配额 + 增加延迟

两条路径都是 silent failure前者 fallback、后者重传生产环境只会以"模型答非所问"或"API quota 异常上涨"间接显现CI 测试很难触发。结论:在迁移步骤 #1#6 全部落地之前,任何 v1 ↔ v2 投影层shim、adapter、序列化器都必须把 purpose 列入显式透传清单,不能默认依赖 schema 不变。

Phase 2 Batch 0 实施回顾2026-05

Batch 0 早期引入过 src/shared/file/legacy/toFileMetadata shimFileEntry → FileMetadata 投影),按本节"迁移目标"假设 purpose 终态从 FileMetadata 剥离,直接丢弃了 purposetokens 字段。但当时 fileProcessor / OpenAIService 这些 v1 消费方尚未迁移,于是上面两条 silent failure 模式都被实际触发。

后来 renderer 侧 FileManager 的 v2 IPC cutover 整体回滚(详见 PR #15067 / 1fe5d3d34toFileMetadata 一并删除。当前路径恢复为 v1 metadata 全链路携带 purposesilent failure 不再可触发。

教训:

  • v2 终极目标"删 purpose"不等于"过渡期可丢 purpose" —— 迁移先后顺序很关键
  • 引入任何 FileEntry → FileMetadata 适配代码时,应该先核对 §2.2 现状调研里所有消费方都已迁移完毕这次漏掉就是因为只看了一处self-review audit 仅列 fileProcessor漏了 OpenAIService —— 详见 PR #15067 thread PRRT_kwDOL_2xws6EeQIz

设计修订2026-05purpose 决定不属于 fileProcessor

延伸自上述 Batch 0 回顾的更深层观察 —— purpose 的赋值动作本身就不该出现在 fileProcessor 这一层

  • fileProcessor 是"AI 调用参数准备"层,它的职责是把 file metadata 组合成 LLM 请求体
  • purpose 是 OpenAI Files API 的 provider-specific 概念,与 LLM 请求体本身无关
  • qwen-long / qwen-doc → 'file-extract' 这种 model-name → purpose 的映射,本质是 upload service 的内部知识

把这层逻辑放在 fileProcessor 是双重越界fileProcessor 既不该懂 OpenAI Files API 的 purpose 枚举,也不该懂 provider-specific 的 "model name 启发式映射"。把决策结果再 spread 回 file 对象(file = { ...file, purpose: ... })则更糟,污染了 file 的语义并把 provider 细节暴露给所有下游消费者。

修订方案purpose 决策完全收敛到 FileUploadService 内部,外部 caller 不再接触此字段:

// 修订后的 OpenAIServiceconcept
async uploadFile(file: FileMetadata, context?: { model?: Model }): Promise<...> {
  const purpose = inferPurpose(context?.model)  // service 内部的 model → purpose 映射
  return this.client.files.create({ file: /* read stream */, purpose })
}

// 修订后的 fileProcessorconcept
await window.api.fileService.upload(provider, file, { model })  // 不再 spread purpose

如果 caller 确实需要覆盖 service 的默认决策(极少数情况),可以暴露显式 options.purpose 作为 escape hatch —— 但这是逃生舱,不应当作主路径

对前述"迁移步骤"的影响

§2.2 "迁移步骤" 表中的步骤 #1 / #2 / #4 应改为:

原步骤 修订后
#1 fileProcessor 提取 purpose 为独立局部变量传入 upload/retrieve fileProcessor 不再决定 purpose调用 upload 时只传 { model } context由 service 内部推断
#2 uploadFile(file, options?: { purpose? }) uploadFile(file, context?: { model?: Model }, options?: { purpose? });内部 inferPurpose(model) ?? options?.purpose ?? 'assistants' 优先级
#4 fileProcessor cache mismatch 比较:remoteFile.purpose !== purpose de-dup 比较职责也挪入 servicefileProcessor 完全不接触 purpose 概念

步骤 #3 / #5 / #6 不变。原则一句话概括:fileProcessor 看不到 purposeservice 内部黑盒处理

2.3 count 字段

决策

v2 不持久化 countfile_entry 表无 count 列;引用计数由 DataApi 专用端点 GET /files/entries/ref-counts?entryIds=... 按需聚合 file_ref 表得出。没有缓存,没有双写,没有 trigger——每次查询都是一次纯 SQL 聚合,固定 shape不再使用 opt-in 参数。

定性:引用计数 → file_ref 表。旧 count 是"这个文件被多少个业务对象引用"的 Dexie-level 数字v2 由 file_ref 表的 COUNT(*) WHERE fileEntryId = ? 取代,完全按需计算

2.3.1 数据面

Dexie schemasrc/renderer/databases/index.ts:45,49,55,62,71,80,92,105,117,128

files: 'id, name, origin_name, path, size, ext, type, created_at, count'

v1-v10 每个版本都把 count 列为索引字段(用于 orderBy('count'))。

初始化值:文件创建时写入 count: 1。所有 setter

位置 场景
src/main/services/FileStorage.ts:274 selectFiles 返回的 FileMetadata
src/main/services/FileStorage.ts:340 uploadFile 单文件
src/main/services/FileStorage.ts:365 base64Image 保存
src/main/services/FileStorage.ts:705 saveBase64Image
src/main/services/FileStorage.ts:755 savePastedImage
src/main/services/FileStorage.ts:1552 download 远程下载保存
src/main/utils/file.ts:151 工具层构造 FileMetadata
src/main/knowledge/preprocess/MistralPreprocessProvider.ts:185 OCR 预处理产物
src/renderer/components/Popups/VideoPopup.tsx:110 视频 popup 构造
src/renderer/pages/knowledge/items/KnowledgeFiles.tsx:113 知识库页构造(传入 uploadFile 前)

2.3.2 Incrementcount++)路径

所有 count++ 都走 renderer 的两条路径:

路径 触发时机
src/renderer/services/FileManager.ts:20 (addFile) 同一文件第二次挂到业务对象record 已存在则 increment
src/renderer/services/FileManager.ts:50 (addBase64File) 同上base64 入口)
src/renderer/services/FileManager.ts:67 (uploadFile) 同上upload 入口)
src/renderer/services/db/DexieMessageDataSource.ts:397-424 (updateFileCount) delta-based 通用更新
src/renderer/store/thunk/messageThunk.ts:1849 消息 fork / clone 时对相关文件 delta=+1

2.3.3 Decrementcount--)路径

路径 语义
src/renderer/services/FileManager.ts:96-119 (deleteFile(id, force=false)) count > 1 → decrementelse → physical unlink + 删 Dexie
src/renderer/services/db/DexieMessageDataSource.ts:397-424 (updateFileCount(-1, deleteIfZero=true)) 同上语义

2.3.4 关键调用方decrement 路径)

以下业务在业务对象被删除时清理 file 引用,全部走 force=false= 走 count decrement

调用方 业务场景
src/renderer/store/thunk/messageThunk.ts:607 删除 message block 时清理附件
src/renderer/store/knowledge.ts:46 删除知识库 item
src/renderer/services/MessagesService.ts:74,83 deleteMessageFiles / safeDeleteFiles
src/renderer/services/db/DexieMessageDataSource.ts:204,252,312,349 block cleanup 的各入口

2.3.5 物理删除路径force=true绕过 count

src/renderer/services/FileAction.ts:45-94 (handleDelete) 是 FilesPage 删除按钮的后端。流程:

  1. FileManager.deleteFile(fileId, true) — 不看 count直接物理删
  2. db.message_blocks.where('file.id').equals(fileId).toArray()手动扫关联 blocks
  3. 遍历 topics 重建 messages[].blocks[] 去除引用
  4. db.message_blocks.bulkDelete(blockIdsToDelete)

这条路径绕开了 count 机制揭示旧架构没有真正的引用完整性保证——count 只是个启发式数字,真正的引用扫描发生在 UI 删除按钮里。

2.3.6 UI 消费

位置 消费方式
src/renderer/pages/files/FilesPage.tsx:52 db.files.orderBy('count').toArray()按引用次数排序全部文件
src/renderer/pages/files/FilesPage.tsx:54 db.files.where('type').equals(fileType).sortBy('count')按类型过滤 + count 排序
src/renderer/pages/files/FilesPage.tsx:111 count: file.count 透传到 dataSource
src/renderer/pages/files/FileList.tsx:102 ${item.count}${t('files.count')}显示引用次数(文件列表每行的 extra 信息)

2.3.7 Migration 侧的残留

src/main/data/migration/v2/migrators/mappings/KnowledgeMappings.ts:103hasCompleteFileMetadata 校验要求 typeof value.count === 'number'。Knowledge 迁移完成后,KnowledgeItemData.file 仍以 FileMetadataSchema 形状存入 SQLite(详见 audit §5 和 RFC §10.6 前言),所以 count 作为 JSON 字段会在 SQLite knowledge_item.data 中化石化保留。

2.3.8 v2 映射

count 不进入 fileEntryTablesrc/main/data/db/schemas/file.ts 已确认无此列)。

v2 对应查询:

-- 旧 file.count
SELECT COUNT(*) FROM file_ref WHERE file_entry_id = ?

-- 旧 orderBy('count')
SELECT fe.*, (SELECT COUNT(*) FROM file_ref fr WHERE fr.file_entry_id = fe.id) AS ref_count
FROM file_entry fe
ORDER BY ref_count DESC

2.3.9 迁移步骤

Step A: FileMigrator 填充 file_refRFC §10.1-10.3 范围内)

迁移器扫描 Dexie 每个文件的所有引用源并写入 file_ref

引用源 扫描方式 file_ref 字段
message_blocks.where('file.id').equals(fileId) → messageId 循环 Dexie 查询 sourceType='chat_message', sourceId=messageId, role='attachment'FILE blockor 'image'IMAGE block
Redux paintings statelocalStorage export JSON 扫描 painting.files[].id sourceType='painting', sourceId=paintingId, role='asset'
Knowledge itemsKnowledgeMigrator 已处理) KnowledgeItemData.file.id sourceType='knowledge_item', sourceId=itemId, role='source'

注意v2 message 迁移把 blocks 从表移入 data.blocks JSON所以 post-migration 不能再用 Dexie-style where('file.id') 扫——必须在迁移期扫完写入 file_ref之后只能靠 sourceId='<messageId>' 反查。

未决点(见 §6 Q7paintings 可能不参与本轮迁移RFC §10.4 标注"不在本次范围内"),因此 paintings 对应的 file_ref 缺失直到 PaintingMigrator 单独上线。Phase 1 通过类型层规避了误删风险:'painting' 不在 FileRefSourceType union 内OrphanRefScanner 根本不会枚举到这个 sourceType任何在迁移窗口期意外写入的 sourceType='painting' 行也会在 FileRefSchema.parse 当场失败。PaintingMigrator 上线时按"三件套"原子加入:allSourceTypes 追加 paintingSourceType + 新增 createRefSchema variant + 在 FileRefCheckerRegistry 注册 SourceTypeChecker——Record<FileRefSourceType, …> 的编译期穷举确保 checker 缺失会直接 TS 报错。Notes 域同理NoteMigrator 上线时按同样三件套加入。

Step B: Renderer 消费改造

按依赖顺序:

# 文件 / 位置 改动
B1 src/renderer/services/FileManager.ts:96-119 deleteFile 的 count 分支删除,改为走新 IPC permanentDelete / trash;语义变化见下
B2 src/renderer/services/FileManager.ts:16-27 (addFile) 不再 count++;改为业务侧file_ref 记录引用
B3 src/renderer/services/FileManager.ts:43-57 (addBase64File), :59-74 (uploadFile) 同 B2
B4 src/renderer/services/db/DexieMessageDataSource.ts:397-424 (updateFileCount) 删除;业务改为直接管理 file_ref
B5 src/renderer/store/thunk/messageThunk.ts:1849 去掉 updateFileCount 调用,改为 fileRefService.create({ sourceType: 'chat_message', sourceId, fileEntryId, role })
B6 src/renderer/store/thunk/messageThunk.ts:607, MessagesService.ts:74,83, DexieMessageDataSource.ts:204,252,312,349, store/knowledge.ts:46 清理语义:从 FileManager.deleteFile(force=false) 改为 fileRefService.cleanupBySource(sourceType, sourceId);文件本体的"无引用清理"交给 OrphanRefScanner
B7 src/renderer/services/FileAction.ts:45-94 (handleDelete) 评估:是否保留 FilesPage 的"强制删除 + 级联清消息 block"v2 如果沿用"主动清引用"则 fileRefService.cleanupByEntry(entryId) + FileManager.permanentDelete(entryId)message block JSON 侧的 stale 引用由 renderer 侧 UI 过滤 dangling 显示
B8 src/renderer/pages/files/FilesPage.tsx:52,54,111, FileList.tsx:102 排序 / 显示 count → 拉 DataApi /files/entries/ref-counts?entryIds=... 取计数,在 renderer 层按 refCount 排序;失效标记走 File IPC batchGetDanglingStates(二者都是独立 useQuery
B9 所有构造 FileMetadata 字面量时写死 count: 1 的位置§2.3.1 后半表) 字段删除(字段级退役完成后)

Step C: Schema 清理

# 位置 改动
C1 src/renderer/databases/index.ts Dexie files 表的 count 索引删除(双写期后)
C2 src/shared/data/types/file/file.ts, src/renderer/types/file.ts FileMetadata.count 字段删除
C3 src/main/data/migration/v2/migrators/mappings/KnowledgeMappings.ts:103 hasCompleteFileMetadata 不再要求 count但迁移器的 legacy 输入仍可能含 count兼容接受即可
C4 main 侧 FileStorage 的 count: 1 写入 删除FileStorage 本身最终会被 v2 FileManager 取代)

2.3.10 语义变化(需要产品确认)

旧语义FileManager.deleteFile(id, force=false)count === 1 时立刻物理删除文件。

v2 两种选择

  • 选项 1立即清理:当 file_ref 最后一行被删时trigger FileManager 立即 permanentDelete(fileEntryId)。需要数据库 trigger 或业务层额外一步
  • 选项 2延迟清理推荐OrphanRefScanner 定期扫描 zero-ref 文件做清理。UX 变化:刚解除最后一个引用后,文件不会立刻消失(但也不再占业务列表)

推荐选项 2,理由:

  • 删除操作原子性:业务侧只管 file_ref不需要跨表 trigger
  • 抗误删:短时间内重新引用该文件不会 fail比如 "undo" 删除一条 message 时)
  • 与 internal deletedAt 软删的哲学一致延迟、可逆external 虽然没有软删状态,但延迟 orphan 清理仍然提供"重新引用不失败"的好处

2.3.11 UI 变化

FilesPage 当前"按 count 排序"提供的价值是"热门文件置顶"。v2 走 DataApi 专用端点 /files/entries/ref-counts(纯 SQL 聚合,固定 shape+ 渲染层组合:

// 1. 拉列表(纯 SQL无副作用
const { data: entries } = useQuery(fileApi.listEntries, { origin: 'internal' })
const entryIds = entries?.map((e) => e.id) ?? []

// 2. 并行拉 refCountDataApi 专用端点)与 danglingFile IPCFS 副作用走 IPC
const { data: refCounts } = useQuery(fileApi.refCounts, { entryIds })
const { data: presence } = useQuery(
  ['fileManager.batchGetDanglingStates', entryIds],
  () => window.api.fileManager.batchGetDanglingStates(entryIds),
  { enabled: entryIds.length > 0 }
)

// 3. renderer 合并后按 refCount 排序
const sorted = entries
  ?.map((e) => ({ ...e, refCount: refCounts?.[e.id] ?? 0, dangling: presence?.[e.id] }))
  .sort((a, b) => b.refCount - a.refCount)

DataApi 边界收紧为纯 SQL + 固定 shapeaggregation 走专用端点(依然是 DataApiFS 副作用DanglingCache + 冷路径 fs.stat)一律走 File IPC。消费者两次 useQuery 并行组合,成本对调用点显式可见。

FileList 的 "$N 引用" 展示:保留此信息对用户有价值(知道哪些文件是被大量复用的),同样走 /files/entries/ref-counts 端点。

建议FilesPage 默认同时发起列表 + refCount 两个 query保留现有排序 UXdangling 由独立 IPC 查询提供失效标记。冷启动首次 list 的 dangling 冷查可能有 N 次 statPromise.all 并行通常 <100ms——调用点把这个成本明示给开发者避免隐藏的 IO 副作用。

2.3.12 执行时机

此字段迁移强绑定域级迁移,不能独立小 PR 完成(不像 purpose

  • B1-B6 需要 file_ref 表 + FileManager + fileRefService 都就绪Phase 2 尾部)
  • B7-B8 依赖 Messages 域迁移Batch E完成——因为 FileAction.handleDelete 手动扫 message_blocks 的逻辑在 Batch E 中会被重写
  • C1-C4 在 Cleanup Batch

因此 count 字段的完整退役贯穿 Phase 2 到 Cleanup Batch,无独立时间点。

2.3.13 风险

风险 缓解
FileMigrator 扫 ref 遗漏某源 → post-migration 孤儿文件 保守策略migrator 失败就不删旧 Dexie filesOrphanRefScanner 延迟启用
Paintings 迁移延后导致 painting 引用的文件被误删 'painting' 不在 FileRefSourceType union 内OrphanRefScanner 无法枚举该 sourceTypePaintingMigrator 上线时按三件套union tuple + schema + checker一并加入
"零 ref 自动删除"UX 变化用户不接受 选项 2 + "最近解除引用"的 Trash 视图补偿;如反馈强烈可做选项 1
FilesPage count 排序是否用户常用?未调研 先保留 /files/entries/ref-counts 端点;用户反馈驱动是否改默认组合

2.4 tokens? 字段

结论:这是一个100% 死字段——从未写入、从未读取。直接删除,零功能影响。

2.4.1 现状

生产方0 个

  • src/main/services/FileStorage.ts 所有 setter 都不写 tokens
  • src/renderer/services/FileManager.ts 的 addFile / uploadFile / addBase64File 都不写
  • src/main/utils/file.ts 不写
  • 任何 MistralPreprocessProvider / VideoPopup / KnowledgeFiles.tsx 的字面量构造都没有 tokens: 赋值

消费方0 个

  • src/renderer/services/TokenService.tsestimateImageTokens(file) 用的是 file.size / 100,不读 file.tokens
  • estimateTextTokens(text) 直接从文本内容算(tokenx lib不碰 FileMetadata
  • UI 没有任何位置展示 file.tokens
  • 业务逻辑没有任何地方读 file.tokens

Dexie schemasrc/renderer/databases/index.ts v1-v10 的 files: 'id, name, origin_name, path, size, ext, type, created_at, count' 没有 tokens 索引

Migration 校验KnowledgeMappings.hasCompleteFileMetadata 不检查 tokens(可选字段,不参与 completeness 判断)

Schema 化石

  • src/shared/data/types/file/file.ts:27tokens?: number
  • src/renderer/types/file.ts:123tokens?: number
  • src/shared/data/types/knowledge.ts:52tokens: z.number().optional()FileMetadataSchema

2.4.2 迁移目标

v2 FileEntry 不保留此字段。token 估算是 TokenService 的职责(消息构造期临时计算),不是文件的属性——这和 purpose 同样的设计原则。

2.4.3 迁移步骤

独立小 PR纯删除

# 文件 改动
1 src/shared/data/types/file/file.ts:27 删除 tokens?: number
2 src/renderer/types/file.ts:123 删除 tokens?: number
3 src/shared/data/types/knowledge.ts:52 FileMetadataSchema 中删除 tokens: z.number().optional()

完。 无 adapter 改动,无 UI 改动,无业务逻辑改动。

2.4.4 未来是否需要缓存 token 估算

不需要在 FileEntry 上挂字段。如果未来 TokenService 发现 estimateTextTokens 对大文件开销大,可以:

  • 内存缓存:Map<contentHash, number> — content hash 作为 key已经由 ops.contentHash 提供),避免相同内容重复算
  • 持久化缓存(如需):独立小表 token_estimate_cache,和 FileEntry 表解耦

这不是 Phase 1 范围。先删掉现有 schema 上的死字段,有需要再按实际 profiling 数据加缓存。

2.4.5 执行时机

独立 PR随时可做。建议和 purpose 字段退役一起打包:refactor(file): drop unused FileMetadata.tokens and .purpose fields2-3 个文件改动,纯删)。

2.4.6 风险

几乎零风险。唯一可能的"惊喜"是外部消费方(非主项目代码)读了这个字段——但本仓的 FileMetadata 不作为公开 API 导出,所以这不是问题。

2.5 type: FileType 字段

定性type: 'image' | 'video' | 'audio' | 'text' | 'document' | 'other'持久化字段变为动态推导。主路径:getFileType(ext) 按扩展名映射;冷路径:isTextFile(path) buffer 探测升级 OTHER → TEXT。

2.5.1 现状

生产方FileStorage 的核心逻辑)

src/main/services/FileStorage.ts:237-242 —— 双层推导:

public getFileType = async (filePath: string): Promise<FileType> => {
  const ext = path.extname(filePath)
  const fileType = getFileTypeByExt(ext)
  return fileType === FILE_TYPE.OTHER && (await this._isTextFile(filePath))
    ? FILE_TYPE.TEXT
    : fileType
}
  • Ext 派生src/main/utils/file.ts:106 getFileType(ext) → 查 fileTypeMap(纯函数)。映射表见 file.ts:20-28 的 imageExts / videoExts / audioExts / textExts / documentExts
  • Buffer 升级FileStorage._isTextFile(filePath)chardet + isbinaryfile 探测FS 副作用,读文件头 sample。仅在 ext 归 OTHER 时触发,把"未知扩展名但内容是文本"的文件升级为 TEXT

所有 FileMetadata setter 同时写 type(连同 count: 1 等字段一起构造):

  • FileStorage.ts:227, 273, 340, 365, 705, 755, 1552 —— 各上传 / 保存入口
  • src/main/utils/file.ts:136-145 —— getAllFiles 目录扫描(getFileType(ext) 派生,无 buffer 升级)
  • src/renderer/components/Popups/VideoPopup.tsx:110 —— 字面量硬编码 VIDEO
  • src/renderer/pages/knowledge/items/KnowledgeFiles.tsx:113 —— 字面量构造
  • src/main/knowledge/preprocess/MistralPreprocessProvider.ts:185 —— 预处理产物构造

Dexie schemafiles: 'id, name, origin_name, path, size, ext, type, created_at, count' —— type 是索引字段,支持 .where('type').equals(...) 查询

v2 占位src/main/file/ops/metadata.ts 已经保留 getFileType(path) / isTextFile(path) / mimeToExt(mime) 三个函数签名,目前 throw new Error('Not implemented')

2.5.2 消费方32 个文件,按类别)

A. Dexie SQL query1 个关键点,必须迁移)

位置 消费方式
src/renderer/pages/files/FilesPage.tsx:54 db.files.where('type').equals(fileType).sortBy('count') —— 按类型过滤 + count 排序,是 FilesPage 左侧栏的核心 query

B. UI 分派 by type大量按 type 分支)

位置 关键分支
src/renderer/services/TokenService.ts:22, 96, 129 TEXT → 读文本内容估 tokenIMAGE → 按 size/100 估 token
src/renderer/services/MessagesService.ts:145 IMAGE → image blockelse → file block
src/renderer/aiCore/prepareParams/fileProcessor.ts:28, 56, 69, 207, 244, 271 TEXT / DOCUMENT / IMAGE 派生 AI SDK FilePart 的不同构造(内联文本 / base64 / URL
src/renderer/aiCore/prepareParams/modelCapabilities.ts 模型能力匹配(支持图像、文档等)
src/renderer/pages/home/Inputbar/context/InputbarToolsProvider.tsx:176 files.some(f => f.type === IMAGE) —— 决定能否 mention non-vision 模型
src/renderer/pages/home/Inputbar/tools/components/useMentionModelsPanel.tsx:103 同上
src/renderer/pages/home/Messages/MessageEditor.tsx:214 IMAGE 走不同编辑逻辑
src/renderer/pages/home/Messages/MessageAttachments.tsx:47 type === undefined 时跳过渲染
src/renderer/pages/knowledge/items/KnowledgeVideos.tsx:112 筛选 VIDEO
src/renderer/utils/messageUtils/create.ts:108, 185 IMAGE / 非 IMAGE 构造不同消息 block
src/renderer/hooks/useAttachment.ts 附件展示分类

C. 类型守卫

  • src/renderer/types/file.ts:140 isImageFileMetadata(file) => file.type === FILE_TYPE.IMAGE

D. Dexie upgrade migrator

  • src/renderer/databases/upgrades.ts:188 —— 历史 Dexie 升级脚本用 file.type === IMAGE

E. Migration 校验

  • KnowledgeMappings.hasCompleteFileMetadata 要求 typeof value.type === 'string'

2.5.3 v2 映射

v2 FileEntry schema 已经没有 typesrc/main/data/db/schemas/file.ts 确认)。迁移方案:

Ext 派生(默认路径)

  • getFileType(ext) 逻辑从 src/main/utils/file.ts 搬到 src/shared/file/types/ 或类似 shared 位置renderer 和 main 都可用
  • src/main/file/ops/metadata.tsgetFileType(path) 实现:从 path 提取 ext → 调 shared 的 getFileType(ext)
  • 零 FS IO

Buffer 升级(保留但收窄)

  • src/main/file/ops/metadata.tsisTextFile(path) 实现:复用 chardet + isbinaryfile 逻辑
  • 只在 FileManager.getMetadata(handle) 时触发——打开文件 / 预览 / 处理路径上;list 查询不触发
  • 旧 FileStorage 在创建时做 buffer 升级v2 在读取时做。语义微调:文件的 type 不再是"持久化属性"而是"每次 getMetadata 现算的派生"

2.5.4 DataApi 的 type filter无 includeType opt-in

FilesPagewhere('type').equals(...) 必须有 DataApi 对应:

方案DataApi query 仅支持 type 过滤(纯 SQL WHERE ext IN (...),作为请求参数;不是 opt-in 输出字段)。不引入 includeType 等派生输出字段——DataApi 边界收紧为纯 SQL + 固定 shape派生 type 由 renderer 端通过共享的 getFileType(ext) 纯函数现算:

// DataApi handler概念代码纯 SQL
async function listEntries(query) {
  const extFilter = query.type ? extsOf(query.type) : null
  return db
    .select()
    .from(fileEntry)
    .where(extFilter ? inArray(fileEntry.ext, extFilter) : undefined)
}

// Renderer 侧用共享工具计算 type纯函数无 IO
import { getFileType } from '@shared/file/types/fileType'
const type = getFileType(entry.ext)

DataApi schema 改动:

'/files/entries': {
  GET: {
    query: {
      ...
      type?: FileType              // 按 type 过滤handler 转成 ext 枚举)
      sortBy?: ... | 'type'         // 可选:按 type 排序字母序SQL ORDER BY ext 等价近似)
    }
  }
}

优点

  • 遵守新的 DataApi 纯 SQL + 固定 shape 边界——不引入 opt-in 派生输出
  • getFileType(ext) 本身是共享纯函数renderer 端零成本调用
  • 不需要物化派生列,不需要 SQLite generated column 特性

代价

  • list 查询拿不到 buffer-upgraded TEXTOTHER 的自定义文本扩展名仍显示为 OTHER
  • 用户体验微降:某些历史 .log/.ini 类文件若无 ext 派生规则覆盖,列表里是 OTHER。用户实际使用打开、send to chat时 File IPC getMetadata 会给出正确 TEXT

2.5.5 迁移步骤

Step A: shared getFileType 提取(独立小 PR

# 文件 改动
A1 src/shared/file/types/fileType.ts(新) fileTypeMapgetFileType(ext)src/main/utils/file.ts 搬过来;导出给 main / renderer / shared 共用
A2 src/main/utils/file.ts:106 re-export shared 版本
A3 src/main/file/ops/metadata.ts 实现 getFileType(path)isTextFile(path)(搬 FileStorage._isTextFile 逻辑)

Step B: DataApi 加 type 支持

# 文件 改动
B1 src/shared/data/api/schemas/files.ts query 仅加 type?: FileType过滤参数response shape 保持固定(FileEntry),不加 opt-in 派生字段
B2 DataApi handlersrc/main/data/api/handlers/files.ts 或类似) 实现 type 过滤 → ext IN (...);派生 type 由 renderer 端通过共享 getFileType(ext) 计算

Step C: FileManager IPC getMetadata 升级

# 文件 改动
C1 src/shared/file/types/common.ts 确认 PhysicalFileMetadata.type 字段语义(含 buffer 升级)
C2 src/main/file/FileManager.ts + 实现 getMetadata(handle) 返回的 type 先 ext 派生OTHER 时 buffer 升级

Step D: 消费者改造30+ 文件)

按子系统分批:

批次 文件 改造模式
D1 AI Core aiCore/prepareParams/fileProcessor.ts, modelCapabilities.ts entry.type === ...getFileType(entry.ext) === ...;如需 buffer 升级的场景TEXT detection改调 FileManager.getMetadata
D2 Messages MessagesService.ts, utils/messageUtils/create.ts, MessageEditor.tsx, MessageAttachments.tsx 同上,主要是纯替换
D3 Token TokenService.ts (line 22 / 96 / 129) file.type === TEXTgetFileType(file.ext) === TEXT;调 window.api.file.read 读文本前可以先 getMetadata 拿准确 type
D4 Input/Attachments InputbarToolsProvider.tsx, useMentionModelsPanel.tsx, useAttachment.ts 纯替换
D5 Knowledge KnowledgeVideos.tsx + KnowledgeFiles.tsx 筛选用 ext 派生;构造字面量的位置停止写 type
D6 FilesPage FilesPage.tsx:54 db.files.where('type') → DataApi type query param
D7 Type guard src/renderer/types/file.ts:140 isImageFileMetadata 改签名接 FileEntry(entry) => getFileType(entry.ext) === IMAGE

Step E: Producer 改造(停止写入 type

# 文件 改动
E1 FileStorage.ts 所有 setter:227, 273, 340, 365, 705, 755, 1552 v2 创建 FileEntry 时不写 typeschema 已无此列);旧 FileMetadata 构造期间仍写shim 兼容)
E2 VideoPopup.tsx:110, KnowledgeFiles.tsx:113, MistralPreprocessProvider.ts:185 字面量停止写 type
E3 getAllFiles / 类似枚举目录返回 FileMetadata 的 utils 停止写 type

Step F: Schema 清理

# 文件 改动
F1 src/renderer/databases/index.ts v11+ files 索引去掉 typeDexie 升级)
F2 src/shared/data/types/file/file.ts:22 FileMetadata.type 删除
F3 src/renderer/types/file.ts:111 同上
F4 src/renderer/databases/upgrades.ts:188 旧 Dexie 升级脚本用 file.type === IMAGE,保留(历史 script不执行或改为 getFileType(file.ext) === IMAGE
F5 src/shared/data/types/knowledge.ts:49 FileMetadataSchema.type 删除(注意 knowledge domain 会改 schema 形状,如用 Zod strip 默认行为,旧数据 type 字段被忽略)
F6 KnowledgeMappings.hasCompleteFileMetadata typeof value.type === 'string' 检查删除

2.5.6 Buffer 升级的未决点

_isTextFile创建时触发 upgradev2 改为 getMetadata读取时触发。这个语义变更的影响:

场景 旧行为 v2 行为 评估
用户上传 foo.log.log 不在 textExts 中) FileStorage.getFileType → OTHER → buffer 升级 TEXT → 存 TEXT 上传时只存 ext 不存 typelist 展示为 OTHER打开预览时 getMetadata 升级为 TEXT 有 UX 微降:列表里是 OTHER 但预览能打开
TokenService 对 OTHER 文件估 token file.type === TEXT 不匹配 → 当图片处理size/100 同样的问题——除非 TokenService 先调 getMetadata 注意:这里旧行为就有 bugv2 需要先 getMetadata值得加 test 覆盖)
aiCore 对 OTHER 文件处理 file.type === TEXT / DOCUMENT 不匹配 → 跳过(不内联文本) 无变化

建议

  • 确认 textExts 列表足够覆盖常见文本扩展名(目前 .txt/.md/.html/.json/.js/.ts/.css/.py 等)
  • 在扩展名表里补充 .log.ini.cfg.yaml.yml.toml 等"配置/日志"类常见文本
  • 剩余罕见 ext 接受 list 显示 OTHER 的降级,打开时 getMetadata 升级

2.5.7 复杂度与执行时机

维度 评估
触达文件数 32 + schema 3
改造性质 多数是"表达式替换"entry.type === XgetFileType(entry.ext) === X少量有逻辑调整getMetadata 调用、DataApi query
最难点 FilesPage Dexie filter 迁移 + TokenService 的 buffer 升级调用
复杂度 L

建议拆分

  • PR1: Step A shared getFileType + ops.getFileType + ops.isTextFile 实现(独立基础设施)
  • PR2: Step B DataApi type / includeType(独立 API 扩展)
  • PR3: Step C getMetadata buffer 升级(独立 IPC 扩展)
  • PR4-PR9: Step D 消费者迁移(按子系统 D1-D7 分 PR
  • PR10: Step E + F cleanup

2.5.8 风险

风险 缓解
FilesPage 左侧栏按 type 筛选失效 PR2 DataApi 必须在 PR6(D6) 前 merge
TokenService 对 "未知 ext 但实际文本"的文件估算错误 在 D3 里对 TEXT detection 加调 getMetadata 一步;单测覆盖
Dexie upgrade script 引用 file.type upgrade script 是历史代码保留原状不执行即可v2 migration 不回走 Dexie 升级路径)
Buffer 升级 regression 测试覆盖几种典型:.log 上升为 TEXT、.bin 不升级、大文件 sample size 合理

2.5.9 执行时机

大部分改动需要 v2 FileManager / DataApi 基础设施就绪。基础 PRA/B/C可以在 Phase 2 完成消费者迁移D贯穿 Batch A-ESchema 清理F在 Cleanup Batch。

2.6 path 字段

定性path 是最深的单一依赖。但调研显示旧架构里 path 事实上已经是派生字段——DB 里的 path 列在每次读取时被覆盖,真实 SoT 是 id + ext + userData 的运行时拼接。v2 只需要把"约定式派生"变成"显式 API 派生"。

2.6.1 现状的意外发现

src/renderer/services/FileManager.ts:80-89getFile(id)

static async getFile(id: string): Promise<FileMetadata | undefined> {
  const file = await db.files.get(id)
  if (file) {
    const filesPath = cacheService.get('app.path.files') ?? ''
    file.path = filesPath + '/' + file.id + file.ext   // 🔑 读出来就覆盖
  }
  return file
}

Dexie files 表虽然有 path 列('id, name, origin_name, path, size, ext, type, created_at, count'),但每次读出来 renderer 都覆盖为运行时计算值。也就是:

  • 存储的 path 是死数据(旧值不会被消费,除非绕过 getFile 直接 db.files.get
  • 真正的 path 计算永远是{userData/files}/{id}{ext}internal
  • FileManager.getFilePath(file)FileManager.getSafePath(file) 在此基础上各自包装

所以迁移不是"引入 path resolution",而是"把隐式约定变成显式 API"——v2 FileEntry 不存 path 列(src/main/data/db/schemas/file.ts 已确认),消费者显式通过 helper / IPC 拿 path。

2.6.2 两层访问器

访问器 位置 语义
FileManager.getFilePath(file) src/renderer/services/FileManager.ts:91 原始计算路径:{filesPath}/{id}{ext}
FileManager.getSafePath(file) src/renderer/services/FileManager.ts:140 危险文件防护:对 .sh/.bat/.cmd/.ps1/.vbs/reg 返回 dirname 而非 file避免 file:// 误点执行
FileManager.getFileUrl(file) src/renderer/services/FileManager.ts:146 返回 file://{filesPath}/{file.name} —— 注意用的是 file.name(存储名 = id+ext),历史遗留

getSafePath 的危险文件防护必须在 v2 保留——否则 <img src="file://...sh"> 可能导致用户通过预览触发 shell 脚本。

2.6.3 消费方分类(~20 个文件)

C1. file:// URL 用于 UI 展示3 个,高频):

位置 用途
src/renderer/pages/home/Inputbar/AttachmentPreview.tsx:109, 112 鼠标悬停附件 tooltip 图片预览
src/renderer/pages/home/Messages/MessageAttachments.tsx:39 消息内附件图片
src/renderer/pages/home/Messages/Blocks/ImageBlock.tsx:22 行内图片 block 渲染

这些都是同步拿 file:// URL 渲染 <img>不能改成 async IPC(大列表下 async 会产生大量 waterfall 延迟)。

C2. 系统级 open / reveal3 个):

位置 操作
src/renderer/pages/files/FilesPage.tsx:105 openPath(getFilePath(file))
src/renderer/hooks/useAttachment.ts:26 非文本 → openPath(path)
src/renderer/pages/home/Inputbar/AttachmentPreview.tsx:127-129 点击文件名 → preview 或 openPath

可以改为 FileManager.open(handle) IPC已在 src/shared/file/types/ipc.ts 定义)。

C3. FS 内容读取4 个):

位置 操作
src/renderer/pages/translate/TranslatePage.tsx:501, 528, 531 isTextFile / readExternal / readText
src/renderer/pages/home/Inputbar/AttachmentPreview.tsx:159 isTextFile(path)
src/renderer/pages/home/Inputbar/components/InputbarCore.tsx:465 readExternal(path, true) 读内联 txt
src/renderer/utils/file.ts:113 isSupportedFile(path, extensionSet)

可以改为 FileManager.read(handle)FileManager.getMetadata(handle)(已定义)。

C4. 系统路径 interop2 个):

位置 操作
src/renderer/pages/agents/components/AgentSessionInputbar.tsx:395 files.map(f => f.path).join('\n') 作为消息文本传给 agent
src/renderer/services/NotesService.ts:191-193 收集 path 上传 —— 注意:这里是 Electron 扩展的 File.path(浏览器 File 对象 + .path 属性),不是 FileMetadata.path。和本次迁移无关

C4 的 AgentSessionInputbar 是真的需要 FileMetadata.path因为 agent 的上下文里要让 LLM 看到本地路径。

C5. Path 派生信息(可直接用 ext

位置 操作
src/renderer/pages/translate/TranslatePage.tsx:492 getFileExtension(file.path) —— 但 file.ext 已经有
src/renderer/components/ObsidianExportDialog.tsx:110, 289 fullPath 作为 keyfiles.find(f => f.path === value)

C6. OCR / 第三方库main 侧)5 个):

位置 操作
src/renderer/services/ocr/OcrService.ts:17 log
src/renderer/services/ocr/clients/OcrExampleApiClient.ts:13 example
src/main/services/ocr/builtin/TesseractService.ts:74 fs.stat(path)
src/main/services/ocr/builtin/OvOcrService.ts:123 ocrImage(path, options)
src/main/utils/ocr.ts:27 readFile(file.path)

第三方 OCR 库只能接受路径参数——这是 FileManager.withTempCopy(handle, fn) 的经典场景(已在 IPC 定义)。

C7. Main 侧 knowledge readers3 个):

位置 操作
src/main/services/knowledge/readers/KnowledgeFileReader.ts:16, 40-45 reader.loadData(file.path)
src/main/knowledge/embedjs/loader/index.ts:60, 78 filePath: file.path
src/main/knowledge/preprocess/PreprocessingService.ts:24, 29 log

Main 侧消费者可以直接用 main 的 resolvePhysicalPath(entry)src/main/services/file/utils/pathResolver.ts),无需 IPC。

2.6.4 v2 映射策略

不引入新 IPC。现有 File IPC 已经覆盖所有需求:

旧 renderer 代码 v2 替代
'file://' + FileManager.getSafePath(file) 新 renderer helper entryToFileUrl(entry) —— 同步拼接
FileManager.getFilePath(file) 新 renderer helper entryToAbsolutePath(entry) —— 同步拼接
window.api.file.openPath(path) window.api.fileIpc.open(createFileEntryHandle(entry.id))
window.api.file.isTextFile(path) window.api.fileIpc.getMetadata(handle) 返回 type含 buffer 升级)
window.api.file.readText(path) window.api.fileIpc.read(handle, { encoding: 'text' })
window.api.file.readExternal(path, true) window.api.fileIpc.read(handle, { encoding: 'text' }) —— path handle 直接走 ops
getFileExtension(file.path) file.ext
OCR thirdPartyLib(file.path) fileManager.withTempCopy(entryId, path => thirdPartyLib(path))

2.6.5 Path resolution 通过 File IPC + 共享格式化 util 统一提供

原则Renderer 不知道内部存储布局({id}.{ext} 拼接方式、userData 路径)。所有 path 来源归到 main 的 resolvePhysicalPath(entry),通过 File IPC 专用方法暴露给 renderer——不走 DataApi因为 DataApi 边界收紧为纯 SQL + 固定 shape任何 main-side 计算resolver 调用)都算越界。

一对 File IPC + 一组共享格式化工具覆盖所有 renderer 需要 path / URL 的场景:

渠道 返回形态 服务场景
File IPC getPhysicalPath / batchGetPhysicalPaths 原始绝对路径 FilePath C4 agent 上下文、drag-drop、subprocess spawn以及作为 <img src> URL 的输入
共享纯函数 toSafeFileUrl(path, ext)@shared/file/urlUtil file:// URL + 危险文件 safety wrap C1 <img src> / <video src> 显示(在 renderer 进程内合成)

File IPC 接口src/shared/file/types/ipc.ts K 段):

interface FileIpcApi {
  // ...
  getPhysicalPath(params: { id: FileEntryId }): Promise<FilePath>
  batchGetPhysicalPaths(params: { ids: FileEntryId[] }): Promise<Record<FileEntryId, FilePath>>
  // 注:不再有 getSafeUrl / batchGetSafeUrls —— 参见下方"为什么 URL 不走 IPC"
}

共享 URL 工具src/shared/file/urlUtil.ts纯函数main + renderer 共用):

export function isDangerExt(ext: string | null): boolean         // 危险扩展名策略
export function toFileUrl(path: FilePath): FileURLString         // 跨平台 file:// 编码
export function toSafeFileUrl(path: FilePath, ext: string | null): FileURLString
  // = isDangerExt(ext) ? toFileUrl(dirname(path)) : toFileUrl(path)

Handler 实现(只剩 getPhysicalPath

// main 侧处理:每个 id 经过统一路径解析
async function batchGetPhysicalPaths(ids: FileEntryId[]) {
  const entries = await fileEntryService.batchGetById(ids)
  return Object.fromEntries(entries.map((e) => [e.id, resolvePhysicalPath(e)] as const))
}

Main 侧基础函数(保留不变):

// src/main/services/file/utils/pathResolver.ts (existing)
export function resolvePhysicalPath(entry): string { ... }  // 绝对路径authority 源头)

为什么 URL 不走 IPCfile:// URL 是对已有 path 做纯字符串格式化 + 危险扩展名的策略判断。两者都可以放在共享包里作为纯函数暴露:

  1. Authority 仍在 main —— resolvePhysicalPath 定义"id + ext 如何拼接、userData 在哪、是否 hash-bucket"
  2. Formatting 是 locality —— toFileUrl / toSafeFileUrl 只接受一个已经由 main 权威产出的 path string,不产生新的 authority。同一份 util 在 main / renderer 都能调,无需来回打桩

这样比跨 IPC 暴露 getSafeUrl 更薄、表面更小,且 main 将来也能直接 toSafeFileUrl(path, ext)webContents.loadURL 用,不需要再复制一份逻辑。

为什么 path 还要走 IPC不纯函数:因为 path resolution 依赖 userData 位置 + 未来可能的 hash-bucket 等存储布局决策——那是 authority必须留在 main。Renderer 拿到 path 后做的 URL 包装才是 formatting可以下放。

为什么走 File IPC 而不是 DataApi 的 opt-in 字段

旧设计曾用 DataApi includePath / includeUrl opt-in 字段统一暴露,理由是"和 refCount / dangling 对称,一次 query 拿齐"。但这把主进程计算resolvePhysicalPath)藏到 DataApi handler 里,违反了 DataApi 严格的纯 SQL 交接边界——DataApi 必须只做 SQL任何 main-side 副作用FS stat、resolver 调用、in-memory cache 查询)一律走 File IPC。

新设计把 path 查询搬到 File IPC 的专用批量方法URL 格式化放到共享纯函数:

  • Renderer 两步组合:先 DataApi 拉固定 shape 的 entry 列表,再 File IPC 批量拿 path需要 URL 就在进程内 toSafeFileUrl 合成
  • 每一步 cost 在调用点显式可见IPC 数量减半 + formatting 无成本)
  • DataApi 响应 shape 保持固定,缓存行为可预测

收益(相比 renderer 拼路径 + 旧 DataApi opt-in + 早期 IPC 双方法三套方案):

维度 Renderer helper作废 DataApi opt-in作废 IPC 双方法 getPath+getSafeUrl作废 本方案IPC getPath + 共享 util
Renderer 知晓存储布局
Main 改存储格式 renderer 需同步改 renderer 无感 renderer 无感 renderer 无感
危险文件 safety 责任 重复在 renderer 集中在 main 集中在 main 集中在共享 utilmain + renderer 同一份)
Null byte 安全检查 renderer 需补 resolvePhysicalPath 已有 同左 同左
DataApi 边界纪律 n/a 破坏 遵守 遵守
IPC 表面 n/a 0 个(塞进 DataApi 4 个path + url 各 2 2 个(只有 path
<img src> cost 同步字符串拼接 列表 query 内部 map 逐条 独立 useQuery + IPC 0 IPCpath 已有URL 就地合成)

Renderer helper 方案、DataApi opt-in 方案、IPC 双方法方案皆已作废。

2.6.6 迁移步骤

Step A: Path resolution 基础设施(独立 PR

# 文件 改动
A1 src/main/services/file/utils/pathResolver.ts 已有 resolvePhysicalPath(entry)保持不变(不再新增 resolveSafeUrl——URL 合成下放到共享 util
A2 src/shared/file/urlUtil.ts(新) 共享纯函数:isDangerExt(ext) + toFileUrl(path)(跨平台 file:// 编码)+ toSafeFileUrl(path, ext)(危险文件 → dirname wrap
A3 src/shared/file/types/ipc.ts 声明 File IPC 新增方法:getPhysicalPath / batchGetPhysicalPaths(均为 managed-entry-only接受 FileEntryId
A4 File IPC handlerFileManager 实现 getPhysicalPath / batchGetPhysicalPaths:内部调 resolvePhysicalPath(entry);批量方法内部 Promise.all 并返回 Record<id, path>

Step B: C1 显示 URL 迁移

# 文件 改动
B1 AttachmentPreview.tsx:109, 112, 127 'file://' + FileManager.getSafePath(file) → 独立 useQuery 调 File IPC batchGetPhysicalPaths(ids)FilePath,渲染时 toSafeFileUrl(paths[entry.id], entry.ext) 合成 URL
B2 MessageAttachments.tsx:39 同上
B3 ImageBlock.tsx:22 同上

所有 C1 消费者组合两个 useQueryDataApi 拉 entry 列表(固定 shapeext+ File IPC batchGetPhysicalPaths 批量拿 path。URL 由共享纯函数 toSafeFileUrl(path, ext) 就地合成——零额外 IPC。重复使用场景建议包一个 useEntriesWithUrl(ids) hook。

Step C: C2 open/reveal 迁移

# 文件 改动
C1 FilesPage.tsx:105 window.api.file.openPath(FileManager.getFilePath(file))window.api.fileIpc.open({ kind: 'entry', entryId })
C2 useAttachment.ts:26 同上
C3 AttachmentPreview.tsx:127-129 preview(path, name, type, ext) → 改 preview 签名接 FileEntry 或 handle

Step D: C3 FS 内容读取迁移

# 文件 改动
D1 TranslatePage.tsx:501, 528, 531 所有 isTextFile(path) / readExternal(path, true) / readText(path)fileIpc.getMetadata(handle) / fileIpc.read(handle)
D2 AttachmentPreview.tsx:159 isTextFile(file.path)fileIpc.getMetadata(handle)
D3 InputbarCore.tsx:465 readExternal(targetPath, true)fileIpc.read(handle)
D4 utils/file.ts:113 isSupportedFile(file.path, ...) → 改签名接 extisSupportedFileExt(ext, ...)),因为真正只需要 ext

Step E: C4 Agent 上下文

# 文件 改动
E1 AgentSessionInputbar.tsx:395 files.map(f => f.path) → 独立 useQuery 调 File IPC batchGetPhysicalPaths(ids)selectedFileIds.map(id => paths[id]).filter(Boolean).join('\n')

Step F: C5 Path 派生消除

# 文件 改动
F1 TranslatePage.tsx:492 getFileExtension(file.path)file.ext
F2 ObsidianExportDialog.tsx:110, 289 entry.identry.name 作为 key不再依赖 path 字符串匹配

Step G: C6 OCR 第三方库迁移main 侧)

# 文件 改动
G1 src/main/services/ocr/builtin/TesseractService.ts:74 通过 fileManager.withTempCopy(entryId, path => ocrLogic(path)) 隔离
G2 src/main/services/ocr/builtin/OvOcrService.ts:123 同上
G3 src/renderer/services/ocr/OcrService.ts:17OcrExampleApiClient.ts:13 log / example非关键路径直接传 entry
G4 src/main/utils/ocr.ts:27 readFile(file.path) → 接收 FileEntry通过 resolvePhysicalPath(entry)

Step H: C7 Main 侧 knowledge readers

# 文件 改动
H1 KnowledgeFileReader.ts:16, 40-45 file.pathresolvePhysicalPath(entry)main 直调)
H2 knowledge/embedjs/loader/index.ts:60, 78 同上

Step I: Legacy accessor 移除

# 文件 改动
I1 FileManager.getFilePath, getSafePath, getFileUrl, getFilefile.path = ... 全部删除(或留 shim 期过渡)
I2 FileMetadata.path 字段 最终删除
I3 Dexie files 索引 path 字段 删除

2.6.7 readExternal 语义(已统一到 read

window.api.file.readExternal(path, asText) 是旧 API。v2 统一到 FileIpcApi.read(handle),不保留别名

  • readExternal(path, true)read({ kind: 'path', path }, { encoding: 'text' })
  • 上下文已经是 FileEntry 时:read({ kind: 'entry', entryId }, ...)

所有调用方必须替换(不存在过渡兼容层)。

2.6.8 Agent 的批量 path 查询

C4 的 AgentSessionInputbar.tsx:395 当前是 files.map(f => f.path).join('\n')——多个文件路径一次拿到。

v2 走 File IPC 专用批量方法 batchGetPhysicalPaths(不走 DataApi——main-side resolver 调用是 DataApi 禁区):

const { data: paths } = useQuery(
  ['fileManager.batchGetPhysicalPaths', selectedFileIds],
  () => window.api.fileManager.batchGetPhysicalPaths(selectedFileIds),
  { enabled: selectedFileIds.length > 0 }
)
const filePaths = selectedFileIds.map((id) => paths?.[id]).filter(Boolean).join('\n')

IPC 批量方法内部 Promise.all 一次 RT 完成——效率等同旧方案,但成本明确落在独立的 useQuery 上,不隐藏在列表 query 的 opt-in flag 里。

2.6.9 关键风险

风险 缓解
危险文件防护丢失 entryToSafePath 必须继承 isDangerFile 逻辑FileManager IPC open(handle) 对 danger ext 应该 refuse 或 open dirname
Image render 性能C1 大列表) Helper 同步拼接,比原 getSafePath 还快(无 db.files.get 调用)
路径字符串作为 key 的代码C5 F2 Obsidian dialog 等改为用 entry.id 作为 key避免 path 字符串字面比较
readExternal 的调用签名迁移 readExternal 可以临时保留(别名到 read({ kind: 'path', path })),逐步淘汰
历史 message block 里内嵌 FileMetadata ChatMigrator 抽取 file.id 建立 file_refsourceType='chat_message');新 message block JSON 只存 fileEntryId;渲染时查 FileEntry。不需要 shim,见 §2.6.10 Q3
Drag-drop 出 Cherry 给 OS Electron drag-drop 需要绝对路径;用 entryToAbsolutePath(entry) 获取

2.6.10 复杂度与执行时机

维度 评估
触达文件数 ~20 + main side 5
改造性质 大多数是"调用 API 替换",不是数据模型改动
最大复杂点 旧 FileMetadata → FileEntry 的适配层shim 期危险文件防护的迁移OCR providers 改 withTempCopy
复杂度 LXL(比 type 重,因为触达了 UI 渲染链路 + main 侧多个子系统)

建议拆分 8-10 个 PRStep A-I 各一):

  • Step Ahelper和 Step B-Frenderer 消费者)可 Phase 2 起步
  • Step G-Hmain 侧)依赖 FileManager 实现就绪
  • Step Ilegacy 清理Cleanup Batch

2.6.11 关键决策记录

Q1: readExternal 的 IPC 别名 已决定

统一到 read(handle),不保留 readExternal 别名。FileHandle 自身区分 entrypath 两种引用形态,旧 readExternal(path, text) 相当于 read({ kind: 'path', path }, { encoding: 'text' })

影响Step D 所有 readExternal 调用必须替换(不能留过渡别名),配套 readText / isTextFile 等旧 IPC 也走 read / getMetadata

Q2: 哪些消费路径真的需要路径字符串?

对前述 C1C7 逐个审查,只有2 个 renderer 类别真的需要路径:

类别 需要 path? 替代/说明
C1 file:// URL 显示<img src="file://..."> × 3 个文件) 需要renderer 直接吃路径字符串) <img>/ <video> 只能给 URL可用 entryToFileUrl 同步拼接
C2 系统 open / reveal 不需要 fileIpc.open(handle) / showInFolder(handle)
C3 FS 内容读取 不需要 fileIpc.read(handle) / getMetadata(handle)
C4 Agent 上下文 embedding 需要LLM 要看到本地绝对路径才能调工具) 需要 path 字符串,但低频(一次 compose可接受 async IPC 或用 renderer 同步 helper
C5 Path 派生信息ext / basename 不需要 entry.ext / entry.name
C6 OCR 第三方库main 侧) main 内部需要 withTempCopy(entryId, fn) 给隔离的临时 pathrenderer 不接触
C7 Knowledge readermain 侧) main 内部需要 main 直调 resolvePhysicalPath(entry)renderer 不接触

结论只有 C1 和 C4 两个 renderer 场景真的需要路径字符串。其中:

  • C1 必须同步image render 路径频繁,不能 async
  • C4 低频一次消息构造async IPC 也可以;但如果 C1 已经要做同步 helperC4 顺带复用即可

这意味着 renderer 侧的 path helper 是必要但最小的:仅为这两个场景存在,不做其他扩展。

Q3: 历史 message block 里内嵌 FileMetadata 的兼容方式 已决定(终态;落地分两步)

终态:历史 message blocks 的 file: FileMetadata 字段在 v2 消息模型里不再内嵌文件对象,而是通过 file_ref 表建立关系(sourceType='chat_message', sourceId=messageId, fileEntryId=..., role='attachment' | 'image')。

落地拆分

  • Batch 0 已落地v2 message block JSON 只存 fileEntryId: stringImageBlock.fileId / FileBlock.fileId),渲染时通过 id 查 FileEntryChatMigrator 已透传 v1 block.file.id → v2 fileId。不需要 shim 反推 path——block JSON 里连 path 都没了。
  • 延后file_ref 反向索引行的写入随 chat 域整体迁移到 v2 file_ref 服务时一并上线(同步注册 'chat_message' sourceType见 §2.10.3 表格里 ChatMigrator 行的延后说明)。延后期间,附件可达性由 inline fileId 维持,仅 file_ref 反查能力暂缺。

这条和 §2.3count 用 file_ref 取代)+ RFC §8.4 ChatMigrator 延后说明一致。

Q4: Path 暴露到 renderer 的方式 已决定(经架构师复核后收紧)

统一通过 File IPC 专用批量方法——renderer 不知道内部存储布局main 作为 path 的唯一来源。

  • getPhysicalPath / batchGetPhysicalPaths → 原始绝对路径agent / drag-drop / subprocess
  • 共享纯函数 toSafeFileUrl(path, ext)@shared/file/urlUtil)→ file:// URL + 危险文件 safety wrap<img src> / <video src>),就地合成,无独立 IPC

为什么不再走 DataApi opt-inDataApi 被严格收紧为纯 SQL + 固定 shape 响应,任何 main-side 副作用resolver 调用、FS stat、in-memory cache 查询)都必须走 File IPC。让 DataApi handler 调 resolvePhysicalPath 会把 main-side 计算藏到"只读 SQL 接口"里破坏边界——consumer 无法从端点签名判断这次调用有没有隐性 IO。

已作废的设计

  • Renderer-side entryToFileUrl / entryToAbsolutePath helper暴露存储布局给 renderer
  • DataApi includePath / includeUrl opt-in 字段(把 main-side 计算混入 SQL 接口)

收益:

  • DataApi 边界纪律明确:看到 DataApi 端点 = 纯 SQL看到 File IPC 调用 = 可能有副作用
  • Renderer 调用成本可见:每次要 path / url 就写一个独立的 useQuery,藏不住
  • Batch 效率等同旧方案IPC 内部 Promise.all
  • Main 改存储格式subdir sharding 等)仍然不影响 renderer
  • 危险文件 safety 集中在 main
  • Null byte 检查自然包含

2.7 name / origin_name 字段

定性:两个字段语义完全不同,需要分别处理。

旧字段 旧语义 v2 归属
name 存储名 = {id}.{ext}(文件系统上的文件名) 删除(由 resolvePhysicalPath(entry) 派生 {id}.{ext},应用层不再看)
origin_name 用户可见原名 = My Document.pdf(含扩展名) 拆分FileEntry.name='My Document'(无扩展名)+ FileEntry.ext='pdf'(无前导点)

2.7.1 name(存储名)现状

ProducerFileStorage 统一构造):所有 FileStorage.ts 的 setter 里 name: uuid + extpath.basename(...) 模式,统一是"id + ext"形态。由 createInternalEntry / ensureExternalEntry 返回给 renderer。

Renderer 中的真实消费者

位置 用途
src/renderer/services/FileManager.ts:148 getFileUrl file://${filesPath}/${file.name} 构造 URL——但 getFileUrl 本身很少被直接调用,多数地方用 getFilePathgetSafePath(已在 §2.6 覆盖)
src/renderer/services/KnowledgeService.ts:135 [${item.file.origin_name}](http://file/${item.file.name}) 构造 markdown 链接
src/renderer/utils/knowledge.ts:211, 222 XML 序列化 <file filename="${fileBlock.file.name}"> —— 传给 LLM
src/renderer/pages/knowledge/components/KnowledgeSearchItem/hooks.ts:54 href: http://file/${item.file.name}
src/renderer/hooks/useKnowledge.ts:134, 138 window.api.file.delete(file.name) 传存储名给删除 API

注意:很多 file.name 实际是 Electron 扩展的 browser File 对象 .name(非 FileMetadataPasteService.ts:72-73, useRichEditor.ts:493, ObsidianExportDialog.tsx:112, VideoPopup.tsx:98-109, NotesService.ts:321Dirent等。这些和本次迁移无关

2.7.2 origin_name(用户可见原名)现状

Producer

位置 语义
FileStorage.ts:215, 267, 315, 358, 698, 748, 1545 path.basename(filePath) 或类似——用户上传时的原始 basename
src/main/utils/file.ts:152 getAllFiles 目录扫描的 basename
knowledge/utils/directory.ts:71 Knowledge 目录扫描
knowledge/preprocess/Mistral/Mineru/Paddleocr 预处理产物继承或改写(如 .pdf.md
VideoPopup.tsx:111, KnowledgeFiles.tsx:114 renderer 字面量构造

Consumer

位置 用途
src/renderer/services/FileAction.ts:18, 19, 37 tempFilesSort 识别 temp_file 前缀;sortFiles 按 name 排序
src/renderer/services/FileAction.ts:100-102 rename 操作:newName 通过 popup 修改 origin_name
src/renderer/services/FileManager.ts:159-175 formatFileName 展示名格式化:pasted_text / temp_file image 特殊 i18n否则返回 origin_name
src/renderer/services/FileManager.ts:151-157 updateFile 自愈逻辑:若 origin_name 没包含 ext补上 ext
src/main/services/remotefile/OpenAIService.ts:31, 46, 57 OpenAI 上传 name: file.origin_name / displayName
src/main/services/remotefile/GeminiService.ts:38, 60, 79 Gemini 上传 displayName
src/main/services/remotefile/MistralService.ts:28, 36, 47 Mistral 上传 fileName / displayName
src/renderer/aiCore/prepareParams/messageConverter.ts:82, 149, 159, 161, 162 AI SDK FilePart fileNamelog / toast
src/renderer/services/KnowledgeService.ts:135 markdown 链接 [${item.file.origin_name}](...)
src/renderer/services/ApiService.ts:473 fileBlocks.map(fb => fb.file.origin_name) 列表
src/renderer/components/RichEditor/useRichEditor.ts:523 alt: fileMetadata.origin_name
src/main/knowledge/preprocess/* 多处 file.origin_name.replace('.pdf', '.md') 派生产物名

Dexie schema: files: 'id, name, origin_name, path, size, ext, type, created_at, count'——两个都是 indexed column。

Migration 校验: KnowledgeMappings.hasCompleteFileMetadata 要求 typeof value.origin_name === 'string'

2.7.3 v2 FileEntry 的新字段语义

// src/shared/data/types/file/fileEntry.ts (already set up)
interface FileEntry {
  id: string; // UUID v7
  origin: "internal" | "external";
  name: string; // 用户可见名字,**不含扩展名**'My Document'
  ext: string | null; // 扩展名,**不含前导点**'pdf'),无扩展名时 null
  size: number;
  externalPath: string | null;
  // deletedAt 仅 internal 可非空external 恒为 nullfe_external_no_delete CHECK
  deletedAt: number | null;
  createdAt: number;
  updatedAt: number;
}

语义变化:

  • name 语义完全改变:从"存储名id+ext"变为"用户可见名(不含扩展名)"
  • ext 语义微调:从"含前导点的扩展名(.pdf"变为"不含前导点(pdf"——见 §2.5 type 字段的 ext 变化
  • 派生的完整显示名 = name + (ext ? '.' + ext : '')

2.7.4 派生工具

增加一个集中式工具shared 或 renderer 均可,逻辑纯函数):

// src/shared/file/utils/displayName.ts
export function entryDisplayName(entry: FileEntry): string {
  return entry.ext ? `${entry.name}.${entry.ext}` : entry.name;
}

所有旧消费 file.origin_name 的位置改为 entryDisplayName(entry)

2.7.5 迁移步骤

Step A: entryDisplayName 工具(独立 PR

# 文件 改动
A1 src/shared/file/utils/displayName.ts(新) 导出 entryDisplayName(entry)

Step B: Producer 改造(设值方)

# 文件 改动
B1 FileStorage.ts 所有 setter:215, 267, 315, 358, 698, 748, 1545 v2 entry 创建时:name 写不含扩展名的 basenameext 写不含前导点的扩展名;不再写 storage name name=id+extstorage path 通过 resolvePhysicalPath 派生)
B2 src/main/utils/file.ts:152 同上
B3 src/main/knowledge/utils/directory.ts:71 同上
B4 src/main/knowledge/preprocess/*Mistral / Mineru / Paddleocr / OpenMineru / Doc2x 预处理产物构造:name 派生(去扩展名),ext 新扩展名
B5 VideoPopup.tsx:98-111, KnowledgeFiles.tsx:113-114 renderer 字面量构造同步

Step C: Consumer 改造 —— 存储名使用点

# 文件 改动
C1 FileManager.getFileUrl (line 148) 已由 File IPC getPhysicalPath + 共享 toSafeFileUrl(path, ext) 覆盖,删除旧 helper
C2 KnowledgeService.ts:135 http://file/${file.name}http://file/${entry.id}(用 id 作为资源标识)
C3 utils/knowledge.ts:211, 222 XML 的 filename="..."entryDisplayName(entry)
C4 KnowledgeSearchItem/hooks.ts:54 href: http://file/${item.file.name}${entry.id}
C5 useKnowledge.ts:134, 138 window.api.file.delete(file.name)fileIpc.permanentDelete(createFileEntryHandle(entry.id))

Step D: Consumer 改造 —— 原名使用点

# 文件 改动
D1 FileAction.ts:18, 19 origin_name.startsWith('temp_file')entry.name.startsWith('temp_file')v2 name 无扩展名)
D2 FileAction.ts:37 a.origin_name.localeCompare(b.origin_name)a.name.localeCompare(b.name)entryDisplayName(a).localeCompare(entryDisplayName(b))
D3 FileAction.ts:100-102 rename popup 编辑 entry.name(不含扩展名),保存时只更新 nameext 独立
D4 FileManager.formatFileName (renamed/ rewritten) 重写:entry.name.includes('pasted_text') 等识别,组合 entryDisplayName;替换旧 formatFileName
D5 FileManager.updateFile:151-157 自愈逻辑 删除v2 name 和 ext 分离,不需要修正)
D6 OpenAIService.ts:31, 46, 57, GeminiService.ts:38, 60, 79, MistralService.ts:28, 36, 47 file.origin_nameentryDisplayName(entry)
D7 messageConverter.ts:82, 149, 159, 161, 162 同上
D8 KnowledgeService.ts:135 markdown 链接 链接文本 [${entryDisplayName(entry)}](...)
D9 ApiService.ts:473 fileBlocks.map(fb => entryDisplayName(fb.file))
D10 useRichEditor.ts:523 alt alt: entryDisplayName(entry)
D11 knowledge/preprocess/* 派生产物名 file.origin_name.replace('.pdf', '.md') → 拆成 name: entry.name, ext: 'md'entryDisplayName 后 replace

Step E: Schema / 迁移

# 文件 改动
E1 src/shared/data/types/file/file.ts, src/renderer/types/file.ts FileMetadata 的 name / origin_name 删除Cleanup Batch
E2 src/renderer/databases/index.ts Dexie files 索引中 name, origin_name 移除v11+ upgrade
E3 src/shared/data/types/knowledge.ts:45 FileMetadataSchemaname / origin_name 删除
E4 KnowledgeMappings.hasCompleteFileMetadata 不再校验 origin_name;改为校验 name(新语义)+ ext
E5 FileMigrator 迁移映射:origin_name → 拆出 nameext;旧 name(存储名)丢弃

2.7.6 ext 的前导点问题(关联 §2.5

FileMetadata.ext = '.pdf'前导点v2 FileEntry.ext = 'pdf'不含)。

所有做字符串比较的消费者要更新:

  • file.ext === '.pdf'entry.ext === 'pdf'
  • file.ext.replace('.', '') 之类的 hacky 写法可以删除
  • Producer 停止写前导点(path.extname() 返回 .pdf,需要 .slice(1).replace(/^\./, '')

这个和 §2.5 type 字段迁移同步进行,可以合并到一个 PR 里。

2.7.7 FileMigrator 的字段拆分逻辑

Dexie origin_name: 'My Doc.pdf' + ext: '.pdf' → v2:

const oldExt = oldFile.ext.startsWith('.') ? oldFile.ext.slice(1) : oldFile.ext
const oldOriginName = oldFile.origin_name
const newName = oldExt && oldOriginName.endsWith('.' + oldExt)
  ? oldOriginName.slice(0, -(oldExt.length + 1))
  : oldOriginName
const newExt = oldExt || null

newFileEntry = {
  ...
  name: newName,  // 'My Doc'
  ext: newExt,    // 'pdf'
}

注意:旧 origin_name 不一定真的包含 ext(可能是 bug 或用户手动改过),做防御性处理。

2.7.8 Rename 行为的语义变化

旧:updateFile({ ...file, origin_name: newName }) 更新含扩展名的原名。用户输入 'My New Doc.pdf' 整个被保存。

v2rename popup 只编辑 nameext 不变。用户看到输入框里是 'My New Doc'(无扩展名),保存后 entry.name = 'My New Doc'ext='pdf' 保留。

UX 注意

  • Rename popup 的输入框 placeholder / tip 应该提示"不含扩展名"
  • 对于 internal 文件:保存 name 只是 DB 更新,物理文件路径({id}.{ext})不变
  • 对于 external 文件:物理 rename 需要重新组合 {newName}.{ext}rename externalPath

这个逻辑在 §2.6 Step I 的 FileManager rename 实现里体现。

2.7.9 复杂度与执行时机

维度 评估
触达 producer ~10 文件
触达 consumer ~15 文件
改造性质 多数是字段访问替换少数需要语义调整ext 前导点、rename 行为)
最难点 Remote upload 服务OpenAI/Gemini/Mistral对 display name 的格式预期;预处理 provider 的产物名派生
复杂度 M(介于 count 和 path 之间)

建议拆分

  • PR1: Step A工具
  • PR2: Step E4 + E5FileMigrator 映射 + schema 校验)—— 迁移基础设施
  • PR3-PR5: Step BProducer分 main 侧 / 预处理 / renderer 字面量三个 PR
  • PR6-PR9: Step D consumer 按子系统分 PRFileAction / TokenService / Remote upload / aiCore & RichEditor 等)
  • PR10: Step E1-E3 cleanup schema

2.7.10 风险

风险 缓解
Remote upload 的 displayName 期望含扩展名 entryDisplayName 默认返回含扩展名形态
预处理 .pdf.md 硬编码替换 改为拆解 ext 后独立设置新 ext逻辑更清晰
origin_name 不含 extbug 数据) FileMigrator 防御性处理ext 为 null 或 name 用整个 origin_name
formatFileNamepasted_text / temp_file 识别 测试数据确认 origin_name='pasted_text_xxx.txt' 迁移后 name='pasted_text_xxx'identifier 保留但无扩展名前缀匹配要调整
Dexie 索引删除 先停止写入,再 v11 upgrade 删索引(可多版本阶段化)

2.8 created_at: string 字段

结论低复杂度。ISO 8601 string → FileEntry.createdAt: numberms epoch。所有消费者已经通过 dayjs 读取,dayjs() 同时接受 string 和 number所以消费侧改动极小主要是 Producer 改写 setter 和 Migrator 做一次类型转换。

2.8.1 现状

v2 目标 schemaFileEntry.createdAt: numberms epoch和其他 v2 表一致(见 src/main/data/db/schemas/file.ts)。

Producer写 ISO string 的位置,通过 .toISOString() / birthtime.toISOString()

位置 来源
src/main/services/FileStorage.ts:224, 270, 336, 361, 701, 751, 1548 各 setterstats.birthtime.toISOString()new Date().toISOString()
src/main/utils/file.ts:154, 325, 361 new Date().toISOString() / stats.birthtime.toISOString()
src/main/knowledge/utils/directory.ts:37, 51, 74 stats.birthtime.toISOString()
src/main/knowledge/preprocess/MistralPreprocessProvider.ts:181 new Date().toISOString()
src/main/knowledge/preprocess/BasePreprocessProvider.ts:57 processedStats.birthtime.toISOString()
src/renderer/components/Popups/VideoPopup.tsx:113 renderer 字面量 new Date().toISOString()
src/renderer/pages/knowledge/items/KnowledgeFiles.tsx:116 renderer 字面量

Consumer通过 dayjs 读)

位置 用法
src/renderer/services/FileManager.ts:164 dayjs(file.created_at).format('YYYY-MM-DD')formatFileName 里显示 pasted_text 日期
src/renderer/services/FileAction.ts:31 dayjs(a.created_at).unix() - dayjs(b.created_at).unix() — 按创建时间排序
src/renderer/pages/files/FilesPage.tsx:114, 115 dayjs(file.created_at).format('MM-DD HH:mm') + dayjs(file.created_at).unix()
src/renderer/pages/home/Inputbar/tools/components/AttachmentButton.tsx:79 dayjs(fileContent.created_at).format('YYYY-MM-DD HH:mm')

关键事实dayjs(x) 同时接受 string (ISO) 和 number (ms epoch)。v2 切换为 number 后,所有 dayjs 消费者代码不变就能工作。

Dexie upgrades(历史修复脚本):

  • src/renderer/databases/upgrades.ts:48-49 — 历史 bug 修复:若 created_at instanceof Date,转成 toISOString()。v2 迁移后 Dexie 不再使用,保留原状即可(不执行)。

Migration 校验

  • KnowledgeMappings.hasCompleteFileMetadataKnowledgeMigrator.ts:71typeof value.created_at === 'string' 要求是字符串(这是输入侧校验,对应旧 Dexie 数据)
  • store/knowledge.ts:74 — 已有 patternnew Date(item.created_at).getTime() 转换到 ms epoch

2.8.2 迁移步骤

Step A: Producer 改造(都是 mechanical 替换)

# 文件 改动
A1 FileStorage.ts:224, 270, 336, 361, 701, 751, 1548 .toISOString().getTime()new Date().toISOString()Date.now()
A2 src/main/utils/file.ts:154, 325, 361 同上
A3 knowledge/utils/directory.ts:37, 51, 74 同上
A4 knowledge/preprocess/MistralPreprocessProvider.ts:181 同上
A5 knowledge/preprocess/BasePreprocessProvider.ts:57 processedStats.birthtime.toISOString()processedStats.birthtimeMs
A6 VideoPopup.tsx:113, KnowledgeFiles.tsx:116 同上

Step B: Consumer 适配(实际上几乎 no-op

dayjs(x) 接受 number所以

  • dayjs(file.created_at).format(...) —— 无需改,自动工作
  • dayjs(a.created_at).unix() —— 无需改

唯一可以简化的:.unix()Math.floor(x / 1000) 更直接,但非必需。

Step C: Schema / Migrator

# 文件 改动
C1 src/shared/data/types/file/file.ts:25, src/renderer/types/file.ts:116 FileMetadata.created_at: stringnumber(或保留 stringv2 新 FileEntry 自己用 numberCleanup Batch 删旧字段)
C2 KnowledgeMappings.hasCompleteFileMetadataKnowledgeMigrator.ts:71 校验改为 typeof value.created_at === 'string' || typeof value.created_at === 'number'(兼容输入)
C3 FileMigrator mapping new Date(oldFile.created_at).getTime() 转换,处理可能的无效 string
C4 src/renderer/databases/index.ts Dexie schema 不需要变仅是类型约束Dexie 不强约束类型)

2.8.3 复杂度与风险

维度 评估
触达 Producer ~11 位置
触达 Consumer ~4 位置(全是 dayjs 无需改)
复杂度 S(仅次于 tokens几乎纯替换

风险

  • 极低dayjs 天然兼容FileMigrator 的 new Date(iso).getTime() 有成熟 pattern
  • 唯一潜在坑historical bug 数据可能是 Date 对象而非 string见 upgrade script :48——FileMigrator 对 created_attypeof 分支兜底,所有不合法值回退到 Date.now()

执行时机:独立小 PR与 §2.4 tokens 类似,可以打包到"字段清理"合并 PR甚至和 ext 前导点迁移合并。

2.9 id: UUID v4 → UUID v7 迁移

决策

保留原 v4 ID;新 entry 自动用 v7。放宽 FileEntryIdSchemaz.uuid() 接受两种。跨表引用零翻译。

2.9.1 现状

v2 设计(当前代码):

  • DB schemasrc/main/data/db/schemas/file.ts:25id: uuidPrimaryKeyOrdered() —— 新 entry 自动生成 UUID v7_columnHelpers.ts:26
  • Zod 校验src/shared/data/types/file/fileEntry.ts:64FileEntryIdSchema = z.uuidv7() —— 严格要求 v7
  • 测试fileEntry.test.ts:188-199):明确断言 v4 校验失败

旧 FileMetadata ID 全部是 v4

位置 生成方式
src/main/services/FileStorage.ts:266, 314, 357 uuidv4()
src/main/utils/file.ts:145 uuidv4()getAllFiles
src/main/services/knowledge/utils/directory.ts:32, 46, 70 uuidv4()
src/renderer/store/thunk/knowledgeThunk.ts:42 uuidv4()

DB 层面 id: text()_columnHelpers.ts:18, 27)——SQLite TEXT 列不约束 UUID 版本v4/v7 字符串都能存。冲突点是 Zod runtime 校验。

2.9.2 两种迁移策略对比

Option A: 保留原 v4 + 放宽 Schema(推荐)

  • FileMigrator 直接把旧 v4 id 写入 file_entry.id
  • 跨表引用message_blocks、paintings、knowledge_items、file_ref无需翻译——原 id 原封不动
  • 放宽 FileEntryIdSchemaz.uuidv7()z.uuid()(接受 v4 和 v7 两种)
  • 新建 entry 仍由 uuidPrimaryKeyOrdered() 默认生成 v7

Option B: 重生成 v7 + ID 映射

  • FileMigrator 对每个旧 entry 生成新 v7 id维护 oldId → newId 映射
  • 所有引用 file 的表message_blocks.file.id、paintings.files[].id、knowledge_items.data.file.id、file_ref.fileEntryId都必须改写
  • 保证 strict v7 invariant

取舍

维度 Option A保留 v4 Option B重生 v7
Migrator 复杂度 低(直接复制) (映射表 + 跨表改写)
迁移失败风险 极低 中(一个引用漏改就是 dangling
DB-level 正确性 v4/v7 共存,但 SQLite TEXT 不关心 纯 v7
v7 time-ordering 好处 新 entry 享有;历史 entry 按 v4 存入(无时序优势但不是问题,已经是历史) 全部享有
Schema 严格性 z.uuid() 接受任何 UUID z.uuidv7() 严格
性能影响 SQLite 对 v4/v7 检索一样快) 无(同)

选 Option A 的理由

  1. v7 的核心优势是"新 insert 按时间顺序",对索引 B-tree 友好历史数据已经插入完成v7 优势对它们不适用
  2. 跨表 ID 翻译极易出 bug每个引用源都要改一个漏掉就是 orphan
  3. z.uuid() 仍然能拦住 garbage 字符串(真正的 schema validation 目的)
  4. 符合 pathResolver 类似决策(信任输入 + 最小化改动面)

2.9.3 迁移步骤

Step A: 放宽 Schema(独立小 PR可在 FileMigrator 之前做)

# 文件 改动
A1 src/shared/data/types/file/fileEntry.ts:64 FileEntryIdSchema = z.uuidv7()z.uuid()
A2 fileEntry.ts:56-63 JSDoc "File entry ID: UUID v7" → "File entry ID: UUID (v4 for legacy migrated entries, v7 for entries created in v2)"
A3 src/shared/data/types/__tests__/fileEntry.test.ts:188-199 现在 assert v4 pass(不再 failv7 pass非 UUID 字符串 fail

Step B: FileMigrator 实现

// 概念代码
async function migrateFileEntry(oldFile: DexieFileRow): Promise<FileEntryRow> {
  return {
    id: oldFile.id, // 保留原 v4 id
    origin: isInternalPath(oldFile.path) ? "internal" : "external",
    name: stripExt(oldFile.origin_name, oldFile.ext),
    ext: normalizeExt(oldFile.ext), // '.pdf' → 'pdf'
    size: oldFile.size,
    externalPath:
      oldFile.origin === "external"
        ? canonicalizeExternalPath(oldFile.path)
        : null,
    deletedAt: null, // Dexie 没软删除字段external 也不允许 trashedfe_external_no_delete
    createdAt: toMs(oldFile.created_at), // ISO → ms
    updatedAt: toMs(oldFile.created_at), // Dexie 无 updatedAt用 createdAt
  };
}

External path 去重(强制):新 schema 的 UNIQUE(externalPath) 禁止同路径两条行。如果 Dexie 里存在多条指向同一 canonical path 的 external FileMetadata由 case / NFD / 拼写差异合并后FileMigrator 必须:

  1. canonicalizeExternalPath(path) 分组
  2. 每组保留一条(建议取 createdAt 最早的)作为 surviving row
  3. 把组内其他 id 收集到 id-remap 表,在迁移 file_ref(以及其他引用 FileMetadata.id 的业务表)时将旧 id 重路由到 surviving id
  4. 被合并掉的 FileMetadata.id 不产生 file_entry

这条 invariant 用 schema 层的 UNIQUE index 强制,任何漏网的重复插入都会在 migrator 内抛出并中止迁移——这是期望行为,引导开发者补全 remap 逻辑而不是让脏数据进库。

Step C: 引用表迁移(对应各自 Migrator

引用表 对应 Migrator 改动
message_blocksfile.id ChatMigrator 原 id 原封写入 file_ref.fileEntryIdQ3 已定)
paintingsfiles[].id PaintingMigrator延后见 RFC §10.4 同上
knowledge_itemsdata.file.id KnowledgeMigrator已有 FileItemData.file.id 保持原值
file_ref 表(迁移时新建) FileMigrator / 各引用源 Migrator 所有 fileEntryId 用原 v4 id

无跨表 ID 翻译——这是 Option A 的主要简化点。

2.9.4 风险

风险 缓解
Renderer 或其他校验点期望严格 v7 全仓 grep FileEntryIdSchemaz.uuidv7() 使用;除 fileEntry.ts 外应该没有其他场景专门依赖 v7 形态
v7 time-ordering 混入 v4 导致"排序不连续" 历史 entry 永远排在 v7 前面v4 第三段第一位是 4v7 是 7——实际上天然把新旧分开如需时间排序按 createdAt 而非 id
未来写入路径误写入 v4 uuidPrimaryKeyOrdered() 保证新 entry 是 v7不存在其他 insert 入口API/IPC 都走 FileManager

2.9.5 复杂度与执行时机

S。唯一实质改动是 schema 放宽 + test 更新。FileMigrator 的 id 字段处理是"直接拷贝"。

执行时机Schema 放宽Step A随时独立 PRFileMigrator 实现Step B在 Batch 0 FileMigrator 整合时落地。

2.10 FileMigrator 整体规约与跨 migrator 协议

本节不重复 §2.2§2.9 的字段映射,而是把 FileMigrator 的整体职责、在引擎中的位置、与业务 migrator 的协作契约、失败处理和观测性串成一份规约。字段级映射的具体规则散在前面各节§2.7 拆分 / §2.8 created_at / §2.9 id 保留 / §2.3 file_ref 来源),本节只在 §2.10.6 用一张交叉引用表回链。

2.10.1 FileMigrator 在 MigrationEngine 中的位置

  • 文件位置src/main/data/migration/v2/migrators/FileMigrator.ts(新增;对齐其他 migrator 的命名风格)
  • idmigrator 唯一标识)'file'
  • 执行顺序(BaseMigrator.order:建议 order = 2.7——在 AgentsMigrator (2.5) 之后、KnowledgeMigrator (3) 之前。这保证:
    • 所有引用 FileEntry 的业务 migratorKnowledge 3 / Chat 4 / 未来 Painting都在 FileMigrator 之后跑
    • 与 BootConfig / Preferences / Assistant 等不引用 file 的 migrator 不存在虚假依赖
  • 依赖
    • MigrationContext.db —— SQLite 写入
    • MigrationContext.sources.dexieExport.tableExists('files') / createStreamReader('files') —— 读 v1 Dexie 导出的 files.json
  • 输出
    • 写入 file_entry 行(来自 v1 Dexie files 表,经 external 去重后)
    • 不写 file_ref——所有 ref 由对应业务 migrator 在迁移引用源时写入(见 §2.10.3
    • MigrationContext.sharedData 写入跨 migrator 协议产物(见 §2.10.3

2.10.2 物理文件命名兼容性验证

前置假设v1 internal 物理文件统一存于 {userData}/Data/Files/{id}{ext}(参考 FileStorage.ts 各 setter 的 name: uuid + ext 模式,其中 v1 ext 前导点,所以拼接后形如 uuid-abc.pdf。v2 resolvePhysicalPath 解析为 {userData}/Data/Files/{id}.{ext}v2 ext 不含前导点,拼接后同样是 uuid-abc.pdf)。

结论v1 / v2 在物理文件名层面字节相同不需要重命名物理文件。FileMigrator 只在 schema 层做 ext 归一化('.pdf' → 'pdf'),磁盘原样保留——与 §5.1 "不移动物理文件" 硬约束一致。

防御性抽样验证FileMigrator 启动时一次性执行):

// 概念代码 — 抽样 20 条 internal 行验证物理存在
const sample = candidateEntries.filter((e) => e.origin === 'internal').slice(0, 20)
let missing = 0
for (const entry of sample) {
  if (!(await pathExists(resolvePhysicalPath(entry)))) missing++
}
if (sample.length > 0 && missing / sample.length > 0.5) {
  throw new MigrationFatalError(
    `Physical file naming assumption violated: ${missing}/${sample.length} sampled internal files not found at {id}.{ext}; aborting before mass orphan`
  )
}

抽样失败率 > 50% 表明命名约定假设已破——例如 v1 用了 {id}_${origin_name} 之类的非标准命名。直接中止迁移并让开发者调查;否则盲目继续会产生大量"DB 有行、磁盘无文件"的孤儿 entry。

抽样通过、个别失败(< 50%)只 recordWarning 不阻塞这些是历史损坏v1 时代就存在。

2.10.3 跨 migrator 协议file_ref 重建

file_ref不由 FileMigrator 写入,而由各业务 migrator 在迁移自己的引用源时同步写入。这把"业务对象 → 引用文件"的关系局部性留在业务 migrator 内部,避免 FileMigrator 反向依赖各业务表。

引用源责任分配

引用源v1 责任 migrator sourceType role 写入时机
message_blocks.file.idFILE block ChatMigrator延后 'chat_message' 'attachment' 随 chat 域整体迁移上线PR #15067 已显式 defer上线 PR 同时把 'chat_message' 加入 FileRefSourceType union + 新增 createRefSchema variant + 注册 SourceTypeChecker。延后期间 'chat_message' 不在 unionOrphanRefScanner 扫不到。v1 block.file.id 已透传为 v2 ImageBlock.fileId / FileBlock.fileIdinline JSON无数据丢失仅缺反向索引
message_blocks.file.idIMAGE block ChatMigrator延后 'chat_message' 'image' 同上
Redux paintings[].files[].id PaintingMigrator延后 'painting' 'asset' painting 域整体迁移上线后;上线 PR 同时把 'painting' 加入 FileRefSourceType union + 新增 createRefSchema variant + 注册 SourceTypeChecker。延后期间 'painting' 不在 unionOrphanRefScanner 扫不到(见 §2.3.9 / §6 Q7
knowledge_items.data.file.id KnowledgeMigrator 'knowledge_item' 'source' 迁移每个 knowledge_item 时同步写(见 §6 Q9 关于现有 loadFileLookup 的去向)
AI provider upload cache (延后到 FileUploadService 本轮不迁;purpose 字段丢弃(见 §2.2

MigrationContext 扩展(跨 migrator 数据传递)

FileMigrator 完成后在 ctx.sharedData 注入两份只读产物:

// 概念代码 — FileMigrator.run 结束前
ctx.sharedData.set('fileMigrator.idRemap', /* ReadonlyMap<oldId, FileEntryId> */)
ctx.sharedData.set('fileMigrator.knownIds', /* ReadonlySet<FileEntryId> */)
  • idRemap 服务 external 去重——canonicalizeExternalPath 合并掉的 loser id 在表里映射到 surviving id。internal 文件不在表里(一对一保留)。
  • knownIds 是所有成功写入 file_entry 的 id 全集,供业务 migrator 校验 "这个 fileId 是否真的迁过去了"。

业务 migrator 通过这两份产物访问 file 子系统,不直接查 DB(保持迁移期单向数据流;同时避免对 fileEntryService 启动顺序产生隐式依赖)。

业务 migrator 调用约定

// 概念代码 — ChatMigrator 迁移 message_block 时
const idRemap = ctx.sharedData.get('fileMigrator.idRemap') as ReadonlyMap<string, FileEntryId>
const knownIds = ctx.sharedData.get('fileMigrator.knownIds') as ReadonlySet<FileEntryId>

async function migrateOneMessageBlock(block) {
  if (block.file?.id) {
    const fileEntryId = idRemap.get(block.file.id) ?? (block.file.id as FileEntryId)
    if (!knownIds.has(fileEntryId)) {
      // FileMigrator 没成功迁过这个 id —— 跳过 ref告警
      this.recordWarning(`message_block ${block.id}: file ${block.file.id} missing in file_entry; ref skipped`)
      return
    }
    await ctx.db
      .insert(fileRef)
      .values({
        fileEntryId,
        sourceType: 'chat_message',
        sourceId: block.id,
        role: block.type === 'IMAGE' ? 'image' : 'attachment'
      })
      .onConflictDoNothing() // 重跑幂等
  }
  // ... 继续写入 message_block ...
}

关键约定

  1. 任何业务 migrator 在写入引用 fileEntryId 之前必须查 idRemap——这是 surviving id 的唯一来源internal 文件查表 miss 时退回原 id即 v4external 文件未经查表写入会引用已合并掉的 loser id
  2. fileRefService.create(或等价 db.insert(fileRef))在迁移期使用 onConflictDoNothing 语义——避免重跑时触发 UNIQUE(fileEntryId, sourceType, sourceId, role) 冲突
  3. 单条 ref 写入失败 → recordWarning 跳过;不整批回滚。理由v1 count 本就是启发式数字,少几行 ref 不破坏迁移完整性;后台 OrphanRefScanner 兜底——漏建的 ref 体现为 file_entry "零引用",由 cleanup UI 展示给用户
  4. fileEntryIdknownIds 缺失FileMigrator 没迁成功)→ recordWarning 跳过整个 ref。比"插入 ref 引用不存在的 entry"安全:后者会破坏 FK 完整性

2.10.4 FileMigrator 失败处理矩阵

失败场景 处理策略
dexieExport.tableExists('files') 返回 false recordWarning 后跳过FileMigrator 不产生任何 file_entry。业务 migrator 后续遇到 file id 查 knownIds miss → warn-skip ref链式安全
单条 file 字段缺失(origin_name undefined 等) recordWarning + skip 该行;不中止迁移
canonicalizeExternalPath 抛出(含 null byte 严重错误:抛 MigrationFatalError 中止——v1 数据已被注入恶意路径,需人工调查
External 去重时 surviving 选择失败 MigrationFatalError 中止
file_entry INSERT 违反 schema CHECKorigin/size/externalPath 三元约束) MigrationFatalError 中止——mapping 逻辑有 bug
§2.10.2 物理抽样失败率 > 50% MigrationFatalError 中止(前条文已规约)
部分 file_entry 写入后引擎中断 整批回滚FileMigrator 在单事务中执行 file_entry 写入);下次启动重跑

回滚原则FileMigrator 的 file_entry 写入在单个 DB 事务内执行;中断 = 全部回滚 = 下次重跑。避免"半迁移"状态下业务 migrator 看到不一致的 knownIds

注:业务 migrator 的 file_ref 写入与 FileMigrator 同事务——业务 migrator 各自有自己的事务边界,靠 §2.10.3 的 idRemap / knownIds 协议保证一致性。

2.10.5 观测性

FileMigrator 完成时发出一条 info-级结构化日志记录(通过 loggerService.withContext('FileMigrator')

// 概念代码 — FileMigrator.run 结束发射
{
  event: 'file-migrator-completed',
  v1FilesScanned: number,            // 输入流读到的 file 行总数
  v1FilesSkippedMalformed: number,   // 缺字段 / canonicalize 失败 等
  v1FilesMerged: number,             // external 去重折叠掉的 loser 数
  fileEntriesInserted: number,       // 成功写入 file_entry 的总数(= scanned - skipped - merged
  sampleVerifyMissing: number,       // §2.10.2 物理抽样未通过的数量(> 50% 已 fatal
  durationMs: number,
}

业务 migrator 在自身收尾阶段各自补一条 *-file-refs-built 记录:

{
  event: 'chat-migrator-file-refs-built' | 'knowledge-migrator-file-refs-built' | ...,
  refsInserted: number,
  refsSkippedMissingEntry: number,   // knownIds miss
  refsSkippedConflict: number,       // onConflictDoNothing 命中(重跑常见)
}

两类记录合起来构成 file 子系统迁移的端到端快照。

2.10.6 与 §2 字段映射节的交叉引用

§2 字段映射节 在 FileMigrator 里的对应动作
§2.2 purpose 字段丢弃(不进 file_entry不入 file_upload后者本轮 deferred
§2.3 count 字段丢弃file_ref 由业务 migrator 写入§2.10.3
§2.4 tokens 字段丢弃(死字段)
§2.5 type 字段丢弃v2 运行时通过 ext 派生)
§2.6 path 仅用于判定 originisInternalPath(path) → internal否则 external + canonicalize 后存入 externalPath
§2.7 name / origin_name 拆分:name ← origin_name 去 extext ← 去前导点;旧"存储名" name 丢弃
§2.8 created_at ISO → ms epochupdatedAt 同值v1 无 updatedAt
§2.9 id 保留原 v4不重新分配避免跨表 id 翻译)

2.10.7 复杂度与执行时机

维度 评估
触达文件数 1 新文件FileMigrator.ts+ MigrationContext 类型扩展 + ~3 业务 migrator 改造(注 file_ref 写入)
改造性质 "读 Dexie → 字段转换 → 去重 → 单事务批量 INSERT" + 业务 migrator 加 file_ref 写入逻辑
最大复杂点 External 去重 surviving 选择 + idRemap 跨 migrator 传递契约
复杂度 M

前置条件 / PR 拆分顺序

  1. §2.9.3 Step A —— FileEntryIdSchema 放宽至 z.uuid()(独立 PR可先行
  2. MigrationContext.sharedData 协议扩展(添加 fileMigrator.idRemap / fileMigrator.knownIds 的 key 与读写工具,建议封装成 getFileMigratorProducts(ctx) helper 避免各 migrator 自己 cast unknown
  3. FileMigrator 本体(含 §2.10.2 抽样验证 + §2.10.3 idRemap 计算 + §2.10.4 失败处理 + §2.10.5 日志记录)
  4. KnowledgeMigrator 改造(决议见 §6 Q9 后落地:在现有 data.file.id 处理流程后追加 file_ref 写入;loadFileLookup 去留按 Q9 结论)
  5. ChatMigrator 改造(延后PR #15067 已 defer与 chat 域 file_ref 服务同 PR 上线,三表面同步——allSourceTypes 增加 'chat_message'createRefSchema 变体、OrphanRefScanner checker
  6. PaintingMigrator 跟随 painting 域整体迁移(不阻塞 v2 主线)

3. 消费域切换计划

总览:详见 filemetadata-consumer-audit.md §6。本节聚焦具体切换顺序和动作

3.1 域切换顺序

批次 复杂度 依赖 预期 PR 数
Batch A Translate / Agent workspace / Export S 字段级退役§2.6 path 1-2
Batch B Paste / 临时文件 / OCR M §2.6 path 2-3
Batch C Painting L §2.3 count 2-3
Batch D Knowledge L §2.3 count + KnowledgeMigrator 就绪 2-4
Batch E Messagesattachments / images XL 所有字段级退役完成 3-5

顺序依据

  • 从小到大,先在低风险域验证适配层设计
  • Messages 最后——它的数据模型最深,且量最大
  • 字段级退役与域切换交错:某域阻塞的字段先做退役,再做域切换

3.2 每个域的切换模板

每个域的切换 PR 遵循相同步骤,逐个域展开时填入具体内容:

  1. 入口枚举:列出该域触发文件操作的所有 UI 入口(哪个 component
  2. 当前数据结构:该域如何持有 FileMetadata直接内嵌数组hashmap
  3. 当前 API 调用:调 window.api.file.* 的哪几个方法
  4. 新 API 映射:每个旧调用对应到 v2 哪个 IPC / DataApi
  5. 数据迁移器:如果该域有 Dexie → SQLite 数据搬运,对应的 Migrator 位置
  6. UI / 行为变化:是否有用户可感知的行为差异(例如 dangling 文件展示)
  7. 测试点:关键集成测试用例
  8. 回滚策略:如果发现严重问题,如何短时间回退

3.3 Batch A-E 详细计划(待调研后逐个展开)

(占位——每个 Batch 按 §3.2 模板展开)

3.4 跨模块切换协调

本节覆盖字段退役§2和 Batch A-E 域切换§3.1)之外的跨模块协调点。这些事项不属于任何单个 consumer 的迁移而是切换期需要全局协调的时序、phasing 和下线计划。

3.4.1 Backup / Restore 与 v2 file 子系统协调

v1 现状

  • src/main/services/BackupManager.ts 通过 fs-extra.copy 复制 userData 目录到打包暂存目录
  • 备份产物包含:物理文件树(Data/Files/*+ Dexie 导出(含 files.json+ LocalStorage / Redux state 导出
  • S3 / WebDAV 远端上传 zip 归档

v2 切换后的结构变化

  • Dexie files 表 Cleanup Batch 删除SQLite file_entry / file_ref 进场
  • 备份产物必须同时容纳:物理文件(位置不变)+ SQLite DB 文件 + 兼容期内仍存在的 Dexie 导出
  • 恢复时必须原子化还原物理文件 + SQLite DB否则会触发
    • 物理文件还原 + DB 未还原 → 启动期 orphan-file-sweep 误删用户文件(命中 §10.4 安全阈值时只是不删,但 count-fraction 触发会变成日常发生)
    • DB 还原 + 物理文件未还原 → file_entry 行指向不存在物理文件,全部变 dangling

改造点(按依赖顺序)

# 文件 改动
B1 src/main/services/BackupManager.ts 备份产物结构扩展:除 Dexie 导出外dump SQLite DB 文件v2 已有的 file_entry / file_ref 表所在 DB。建议路径 <backup>/sqlite/<db-name>.db;先 DbService.checkpoint() 把 WAL flush 到主文件再 copy
B2 BackupManager 恢复路径 增加 SQLite DB 还原步骤:在恢复物理文件之前,先把 backup 中的 sqlite 文件 atomically rename 到目标位置(DbService 启动前完成
B3 BackupManager 版本兼容 检测 backup 产物是 v1无 sqlite 目录)还是 v2含 sqlite 目录。v1 backup 恢复到 v2 环境:保留 Dexie 导出,让 FileMigrator 走正常迁移路径
B4 FileManager.runStartupSweeps / OrphanRefScanner 在「刚完成 backup restore」标志位下首次启动时跳过 orphan sweep 一次——给 DB ↔ FS 一个对齐窗口;标志位由 BackupManager 在 restore 完成时 setFileManager onInit 读取后清空
B5 Backup 产物加 v2 marker <backup>/manifest.jsonformatVersion: 2v1 BackupManager 检测到 v2 marker 时拒绝恢复并提示用户

关键风险与缓解

风险 缓解
旧 v1 backup 没有 sqlite 文件,恢复到 v2 环境 走 FileMigrator 路径Dexie files.json 仍在 backup 里,可作 input
Backup 中途有业务写入,导致 backup 内文件树和 DB 不一致 备份前 DbService.checkpoint() + 暂停业务写入窗口(沿用 v1 既有的 backup window 机制,如不存在需要新增)
Restore 中途崩溃产生半还原状态 所有还原内容先到 staging 目录,全部就绪后原子化 rename 到生产路径;崩溃后 staging 目录可识别并清理
v2 backup 还原到 v1 环境(降级) 不支持v2 marker 检测到 v2 marker 后 v1 拒绝恢复

复杂度L(跨 main 进程多个子系统BackupManager + DbService + FileManager

执行时机:与 Phase 2 同步——FileMigrator 落地后立刻补 BackupManager。不能等 Cleanup Batch,否则 Phase 2 切换期间用户备份会丢失 SQLite 数据。

3.4.2 OrphanRefScanner 启动时机 gate

问题

  • RFC §6.4 规定 OrphanRefScanner 在 Background phase 启动,扫描 file_ref 找 sourceId 不存在的行删掉
  • FileManager 的 runStartupSweeps(启动期 file-sweep + orphan-entry-report同样在 onInit 后 fire-and-forget
  • 如果 MigrationEngine 还在跑FileMigrator 写完 file_entry → 业务 migrator 还没写完 file_refscanner 提早启动会看到「file_entry 但无 file_ref」状态——虽然 RFC §7.1 政策是「preserve」不删 entryorphan-ref-cleanup 还是会扫——而且 §3.4.1 B4 的 backup-restore 场景也需要同样的 gate

规约

# 改动
O1 MigrationEngine 完成所有 migrators包括各业务 migrator 的 file_ref 创建)后,在 ctx.sharedData 写入 'migration.completed': true 或发射 lifecycle Signal<void>
O2 OrphanRefScanner.start()FileManager.runStartupSweeps() 都 await 这个 Signal启动期需要时 polling sharedData 或注入 Signal 依赖)
O3 首次启动DB 刚 migrate 出来OrphanRefScanner 第一次扫描额外跳过一轮——给 PaintingMigrator 延后期、Phase 2 业务切换期一个宽限窗口(用 firstRunAfterMigration 标志)
O4 文档明确「OrphanRefScanner 不在 migration / restore 进行期间扫描」是规约,不是 best-effort

复杂度SSignal 接线 + 一行 await + flag bookkeeping

执行时机:与 FileMigrator 落地同一 PR——否则首次 v2 启动就有概率把刚迁过来但 file_ref 还没建好的 entry 错判为 zero-ref虽然 §7.1 政策保留 internal、policy matrix 处理掉 external 0-ref但若 §7.2 deferred 提前实现就会出问题)。

3.4.3 Dexie files 表 phasing

问题§5.1 「保留 Dexie 备份直到 Cleanup Batch」过于粗——Phase 2 切换期间 renderer 是否继续写 Dexie files 表?这条决定了 §4 shim 的有效边界和数据一致性窗口。

phasing 计划

阶段 Dexie files 表状态 写入路径 读取路径
Phase 1 现状renderer 持续读写 FileManager.addFile / uploadFile db.files.where(...) 各处
Phase 2 启动 Frozen:只读,不再接受新写入 新文件全部走 v2 createInternalEntry / ensureExternalEntry Batch A 适配层 toFileMetadata 把 v2 FileEntry 投影回 Dexie 形状给尚未迁的桶 P 消费者
Phase 2 切换期 同上 同上 各 Batch 逐步切到直接消费 FileEntrytoFileMetadata 调用点递减
Cleanup Batch 表删除Dexie v12 upgrade n/a n/a

改造点

# 改动
D1 Phase 2 启动 PRrenderer 端所有 db.files.put / add / update / delete 调用点改抛 DexieFilesFrozenError;保留 db.files.get / toArray / where 仅供 toFileMetadata 适配
D2 useFiles / 旧 FileManager.uploadFile 等业务入口改走 v2 IPC写入产生 FileEntry回写 Dexie
D3 Cleanup Batch PRDexie schema v12 移除 files 表 + 索引;删除 toFileMetadata shim 与 FileMetadata 类型本身

关键决策

  • 无双写期——Phase 2 启动就 freeze。理由双写期需要维护 Dexie ↔ SQLite 一致性,工程成本高于 shim 的读取适配
  • shim 单向FileEntry → 旧 FileMetadata 形状)已经在 §4.1 规约§3.4.3 与 §4.1 一致

复杂度Mrenderer 多处入口要审计 + DexieFilesFrozenError 接线)。

执行时机Phase 2 启动作为 Batch A 之前的 prerequisite PR。

3.4.4 v1 window.api.file.* preload API 下线顺序

问题v1 preload 暴露 49 个 file 相关 APIFile_Read / File_Write / File_Upload / File_Delete / onFileChange 回调 ...。§4.3 提到 Cleanup Batch 删 FileMetadata 类型和 FileStorage,但没列 preload API 下线顺序——49 个 channel 逐个下线是 Cleanup Batch 的实际工作量。

分类与下线规则

类别 下线时机
完全被 v2 IPC 取代 read / write / writeWithId / delete / rename / move / save / open / showInFolder / binaryImage / base64Image / base64File / pdfInfo / isTextFile / isDirectory 对应 Batch 完成后立刻 deprecateCleanup Batch 删 preload entry + main 端 handler
被 v2 取代但调用点散落 readExternal / saveBase64Image / savePastedImage / download / copy Batch C-E 期间逐步迁移到 read({encoding}) / getMetadata / createInternalEntry({source:'url'})Cleanup Batch 删
保留v2 设计上承接、签名不变) select / selectFolderElectron dialog/ openPath / getPathForFile 保留——这些是 Electron 原生能力封装,不属于 file 子系统范畴
watcher 相关 startFileWatcher / stopFileWatcher / pauseFileWatcher / resumeFileWatcher / onFileChange 改走 createDirectoryWatcher + 业务自己的 IPC 转发协议(见 §3.4.5 注)
业务自治 clear / mkdir / validateNotesDirectory / getDirectoryStructure / batchUploadMarkdown / checkFileName 由 Notes / Knowledge 自治;保留或迁到对应业务 module 的 IPC不进 window.api.file.*

改造原则

  • 每个 Batch 完成时 deprecate 自己用到的 v1 APIpreload 加 @deprecated JSDoc + 首次调用 console.warn 一次,避免日志洪水)
  • Cleanup Batch PR 一次性 delete preload 暴露 + 对应 IPC handler 注册

复杂度M49 个 API 逐个 audit多数是机械下线少数需要先确认所有调用点都已切换

执行时机:各 Batch 完成时 deprecateCleanup Batch 一次性删除。

3.4.5 remotefile/* services 过渡期生命周期

v1 现状

  • src/main/services/remotefile/{Gemini,OpenAI,Mistral}Service.ts 实现 BaseFileService 接口(uploadFile / retrieveFile / listFiles / deleteFile
  • chat 流程通过 window.api.fileService.upload(provider, file) 走这些 services
  • 上传缓存机制:fileProcessor.ts 在上传前查 cached fileId,命中则跳过

v2 终态

  • FileUploadService + file_uploadRFC §9.8 Phase X依赖 Vercel AI SDK 稳定)
  • chat 流程改走 FileUploadService.ensureUploaded(entryId, provider)cache 由 file_upload 表持久化

过渡期协议

阶段 chat 上传路径 输入
Phase 1 / Phase 2 启动 remotefile/* services 不变;fileProcessor.ts 改读 v2 FileEntry 后调 entryDisplayName(entry) 拼 displayName§2.7 D6 FileEntryv2BaseFileService API 接口不变
Phase 2 Batch BAI Core fileProcessor.ts / messageConverter.ts 切到 v2 APIremotefile/* services 维持不变 同上
Phase XFileUploadService 落地) FileUploadService.ensureUploaded 接管;remotefile/* services 标 @deprecated FileEntry + file_upload cache
Phase X+1清理 remotefile/* services 删除 n/a

关键决策

  • Phase 2 切换等 FileUploadService——remotefile/* services 继续担当 upload 通道,仅需在 displayName / purpose 字段层对接 v2 schema已在 §2.2 / §2.7 规约)
  • 过渡期不引入新 cache 表,fileProcessor.ts 旧 cache 逻辑临时保留在 renderer 内存里——避免 file_upload 表设计在 Vercel AI SDK 稳定前被锁死
  • watcher 转发协议§3.4.4 watcher 类):业务模块用 createDirectoryWatcher 后,自定义 IPC channel建议 <module>-event,如 notes-event)把事件转发给 renderer不复用 file-manager-event 通道——后者由 FileManager 占有,业务事件不应混入

复杂度S(字段层适配已在 §2.2 / §2.7 规约,本节只追加生命周期约定)。

执行时机Batch B 完成Phase X 落地时进入下线倒计时。

3.4.6 §3.4 各项的总览

子节 主题 严重性 与 Phase 关系 落地 PR
3.4.1 Backup / Restore 协调 🔴 Phase 2 同步(不能等 Cleanup Batch 与 FileMigrator 同期或紧随
3.4.2 OrphanRefScanner gate 🔴 与 FileMigrator 同 PR 同 FileMigrator PR
3.4.3 Dexie files 表 phasing 🟡 Phase 2 启动 prerequisite Batch A 之前
3.4.4 preload API 下线 🟢 各 Batch deprecateCleanup Batch 删除 散落 + Cleanup Batch PR
3.4.5 remotefile/* 过渡期 🟢 Batch B 字段层Phase X 下线 Batch B字段+ Phase X+1

4. 适配层Shim设计

Scope 限定shim 只服务桶 P——给还没迁移到 FileEntry 的持久化消费者Dexie / message_block 内嵌 / knowledge_item JSON临时的"看起来像 FileMetadata"视图。

不提供的 shim

  • 桶 I 无 shim——FileMetadata → FileInfo 是同端字段瘦身,直接改签名
  • 桶 A 无 shim——两栖消费者必须拆签名解决(持久化动作走 createInternalEntry / ensureExternalEntry 显式升格,处理动作接 FileInfo),用 shim 糊弄会让 A 桶永远清不掉
  • 反向 shimFileMetadata → FileEntry——不提供;FileInfo → FileEntry 必须走 FileManager sanctioned 生产入口brand 强制),伪造的 FileEntry 会被 Zod 拒收

4.1 toFileMetadata(entry: FileEntry, physicalPath: FilePath): FileMetadata —— 桶 P 过渡期专用

// 概念代码。仅在未迁移的桶 P 消费者落地点使用;迁移完成后随消费者一起删除
function toFileMetadata(entry: FileEntry, physicalPath: FilePath): FileMetadata {
  return {
    id: entry.id,
    name: entry.ext ? `${entry.id}.${entry.ext}` : entry.id, // 旧的"存储名"约定
    origin_name: entry.ext ? `${entry.name}.${entry.ext}` : entry.name,
    path: physicalPath, // via FileManager.resolveForSystem / resolvePhysicalPath
    size: entry.size,
    ext: entry.ext ? `.${entry.ext}` : "", // 注意加回前导点(旧约定)
    type: getFileTypeFromExt(entry.ext), // ops.getFileType
    created_at: new Date(entry.createdAt).toISOString(),
    count: 0, // 需要时查 file_ref旧消费方大多不读
    // tokens / purpose 不填——新系统已剥离
  }
}

注意点

  • ext 的前导点差异(旧带点,新不带)在此处归一
  • count 需要时查 file_ref,默认 0旧消费方大多不读
  • path 需要由 main 侧 resolvePhysicalPath(entry) 预先解析renderer 侧不应自拼

4.2 反向 shim不提供

旧代码产出的 FileMetadata 不能通过同步转换变成 FileEntry——FileEntrySchema 的 Zod brand 会挡住伪造。正确路径:

  • 若旧 FileMetadata 源自Dexie 持久化数据:在 FileMigrator 阶段一次性搬运到 file_entry(见 §6
  • 若旧 FileMetadata 源自runtime 生产(如 FileStorage.uploadFile 新建Phase 2 把对应 producer 直接替换成调用 createInternalEntry IPC
  • 仅需要用文件属性OCR / TokenService / 渲染等):这是桶 I 或桶 A 的处理路径——FileMetadata 瘦身成 FileInfo,签名改掉就好

4.3 Shim 的生命周期

  • Phase 2 双读双写期:引入 toFileMetadata(仅桶 P 用)
  • Batch A-E consumer migration 期:随桶 P 消费者逐个迁移到 FileEntryshim 调用点递减
  • Cleanup Batch最后删掉 toFileMetadataFileMetadata 类型本身;FileInfo / FileEntry / FileHandle 成为唯一表达

5. 执行约束与里程碑

5.1 硬约束

  • 不移动物理文件:所有 internal 文件物理位置保持 {userData}/files/{id}.{ext}FileMigrator 只建表、不动盘
  • 不破坏用户数据:每次迁移保留 Dexie 备份直到 Cleanup Batch
  • 可逐域回滚:任何一个域的切换 PR 都能独立回滚,不影响其他域

5.2 里程碑tentative

里程碑 内容 依赖
M1 §2.2 purpose 退役 PR 合入 本文档 §2.2 落地
M2 Shim 双向适配实现§4 FileEntry schema 稳定(已完成)
M3 字段级退役全部完成§2.3-§2.9 每个字段一个独立 PR
M4 Batch A 完成 M2 + §2.6 path
M5 Batch B-D 完成 M4
M6 Batch E 完成 M5 + 所有字段退役
M7 Cleanup删 Dexie files 表 / FileStorage.ts / FileMetadata 类型 M6

5.3 每个 PR 的最小描述模板

切换 PR 应在描述中包含:

  • 对应字段 / 域(引用本文档章节号)
  • 改动范围(文件清单)
  • 是否破坏旧消费方(如有,明确适配层处理)
  • 回滚方法

6. 开放问题与决策追踪

# 问题 状态 决策
1 id v4 → v7 是否保留原 ID 倾向保留 §2.9
2 path 的 renderer-side 消费能否全部改为"不缓存 + 按需查" 未决 §2.6 展开时讨论
3 Shim 层放 renderer 还是 main 倾向 main靠近数据源 §4
4 Phase 2 双读双写期时长 未决 配合产品节奏
5 零 ref 文件自动清理立即vs 延迟v2 推荐选项 2 倾向延迟 §2.3.10
6 FilesPage 按 ref_count 排序是否保留为默认? 倾向保留能力,默认关闭 §2.3.11
7 PaintingMigrator 延后期间 painting 引用的孤儿判定 无需兜底:'painting' 不在 FileRefSourceType union 内OrphanRefScanner 扫不到PaintingMigrator 上线 PR 按三件套union tuple + schema + checker一并加入 §2.3.9 Step A
8 FilesPage.handleDelete 的"强制删除 + 级联清消息 block"是否仍由 renderer 驱动? 未决 §2.3.9 B7
9 FileMigrator 上线后,KnowledgeMigrator.loadFileLookup(现状直接 stream-read v1 files.json 构造 lookup是保留作为业务字段补全的旁路、还是统一改走 ctx.sharedData['fileMigrator.knownIds'] 唯一路径? 未决 §2.10.3 / §2.10.7 Step 4

附录 A术语对照

旧术语 新术语 备注
FileMetadata FileEntry + FileRef + 派生信息 字段拆解见 §2
FileStorage.ts FileManager.ts + ops/* + DanglingCache 职责拆分
window.api.file.* window.api.fileIpc.* + DataApi /files/* 新合约见 RFC §9
Dexie.files SQLite file_entry 表 + file_ref 数据搬运见 RFC §10
file.count 引用计数 file_ref 多态外键 §2.3
file.path 物理路径 FileHandle + 运行时 resolve §2.6

附录 B修订记录

日期 版本 变更
2026-04-19 0.1 初稿:从 RFC §10.6 抽出,建立字段退役 + 域切换两线框架
2026-05-11 0.2 新增 §2.10 FileMigrator 整体规约与跨 migrator 协议(位置/order、物理命名抽样验证、idRemap/knownIds 跨 migrator 传递契约、失败处理矩阵、观测性记录、与 §2.x 字段映射的交叉引用表§6 加 Q9KnowledgeMigrator loadFileLookup 在新协议下的去留)
2026-05-11 0.3 新增 §3.4 跨模块切换协调Backup-Restore 协调 / OrphanRefScanner 启动 gate / Dexie files 表 phasing / v1 window.api.file.* 下线顺序 / remotefile/* services 过渡期);与 RFC §13 同步勾掉对应条目