Back to Cherry Studio

File Manager Migration Plan

v2-refactor-temp/docs/file-manager/migration-plan.md

2.0.0171.2 KB
Original Source

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 的类型注释写着:

typescript
/**
 * --------------------------------------------------------------------------
 * ⚠️ 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 行 / 持久化身份FileEntryidorigindeletedAt;有 lifecycle;Zod brand 强制走 sanctioned 生产路径
磁盘描述符 / 临时传参FileInfopathmodifiedAt;live view;任意构造
跨边界引用(两者通用)FileHandletagged union;IPC 边界首选签名

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

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

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

使用模式迁移目标代表消费者
P 持久化FileMetadata 存进 Dexie / message_block / knowledge_item JSON;读取时期望完整 DB 行→ FileEntry(或其中的 FileEntryIddatabases/index.ts Dexie 表、KnowledgeMigratorImageMessageBlock.file
I 描述符只用 path / name / size / ext / type 驱动一次处理,不持久化任何身份→ FileInfoOCR(SupportedOcrFile、TesseractService)、TokenService、isSupportedFile
A 两栖同一处既持久化又 pass-through,或者签名要完整 FileMetadata 但实际只用子集("接口说谎")→ 拆签名:持久化路径接 FileEntry,处理路径接 FileInfoservices/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 限定

原先打算的"双向 shim(FileEntry ↔ 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📋 计划完成
typeFileInfo-onlyFileInfo.type(ext 派生,默认)+ ops.getMetadataPhysicalFileMetadata.type(按需);FileEntry 不存此列§2.5📋 计划完成
pathFileInfo-onlyFileInfo.path(unmanaged 身份);managed 侧由 resolvePhysicalPath(entry) 动态派生,FileEntrypath§2.6📋 计划完成
name (存储名)彻底删除name = id + ext 的冗余物理命名约定废弃;storage path 由 resolvePhysicalPath(entry) 派生 {id}.{ext}§2.7📋 计划完成
origin_nameFileEntry + FileInfo拆分并重命名:FileEntry.name(不含扩展名)+ FileEntry.ext(不含前导点);FileInfo.name / FileInfo.ext 从 basename 派生§2.7📋 计划完成
created_atFileEntry-onlyISO string → FileEntry.createdAt: number(ms epoch),dayjs 天然兼容;FileInfomodifiedAt(mtime),不持有 entry 级别的创建时间§2.8📋 计划完成
id (UUID v4)FileEntry-only保留原 v4 id;新 entry 走 v7;Schema 放宽为 z.uuid()FileInfo 无 id 字段§2.9📋 计划完成
size(共享)FileEntry + FileInfo字段名不变,但语义分裂:FileEntry.size 是注册时快照(external 可能 drift);FileInfo.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
  • 唯一 setter:src/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

迁移步骤

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

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 shim(FileEntry → FileMetadata 投影),按本节"迁移目标"假设 purpose 终态从 FileMetadata 剥离,直接丢弃了 purposetokens 字段。但当时 fileProcessor / OpenAIService 这些 v1 消费方尚未迁移,于是上面两条 silent failure 模式都被实际触发。

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

教训:

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

设计修订(2026-05):purpose 决定不属于 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 不再接触此字段:

ts
// 修订后的 OpenAIService(concept)
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 })
}

// 修订后的 fileProcessor(concept)
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/retrievefileProcessor 不再决定 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 !== purposede-dup 比较职责也挪入 service,fileProcessor 完全不接触 purpose 概念

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

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

2.3.2 Increment(count++)路径

所有 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 Decrement(count--)路径

路径语义
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,83deleteMessageFiles / safeDeleteFiles
src/renderer/services/db/DexieMessageDataSource.ts:204,252,312,349block 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:52db.files.orderBy('count').toArray()按引用次数排序全部文件
src/renderer/pages/files/FilesPage.tsx:54db.files.where('type').equals(fileType).sortBy('count')按类型过滤 + count 排序
src/renderer/pages/files/FilesPage.tsx:111count: 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 对应查询:

sql
-- 旧 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_ref(RFC §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 block)or 'image'(IMAGE block)
Redux paintings state(localStorage export)JSON 扫描 painting.files[].idsourceType='painting', sourceId=paintingId, role='asset'
Knowledge items(KnowledgeMigrator 已处理)KnowledgeItemData.file.idsourceType='knowledge_item', sourceId=itemId, role='source'

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

未决点(见 §6 Q7):paintings 可能不参与本轮迁移(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 消费改造

按依赖顺序:

#文件 / 位置改动
B1src/renderer/services/FileManager.ts:96-119deleteFile 的 count 分支删除,改为走新 IPC permanentDelete / trash;语义变化见下
B2src/renderer/services/FileManager.ts:16-27 (addFile)不再 count++;改为业务侧file_ref 记录引用
B3src/renderer/services/FileManager.ts:43-57 (addBase64File), :59-74 (uploadFile)同 B2
B4src/renderer/services/db/DexieMessageDataSource.ts:397-424 (updateFileCount)删除;业务改为直接管理 file_ref
B5src/renderer/store/thunk/messageThunk.ts:1849去掉 updateFileCount 调用,改为 fileRefService.create({ sourceType: 'chat_message', sourceId, fileEntryId, role })
B6src/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
B7src/renderer/services/FileAction.ts:45-94 (handleDelete)评估:是否保留 FilesPage 的"强制删除 + 级联清消息 block"?v2 如果沿用"主动清引用"则 fileRefService.cleanupByEntry(entryId) + FileManager.permanentDelete(entryId);message block JSON 侧的 stale 引用由 renderer 侧 UI 过滤 dangling 显示
B8src/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 清理

#位置改动
C1src/renderer/databases/index.tsDexie files 表的 count 索引删除(双写期后)
C2src/shared/data/types/file/file.ts, src/renderer/types/file.tsFileMetadata.count 字段删除
C3src/main/data/migration/v2/migrators/mappings/KnowledgeMappings.ts:103hasCompleteFileMetadata 不再要求 count(但迁移器的 legacy 输入仍可能含 count,兼容接受即可)
C4main 侧 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)+ 渲染层组合:

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

// 2. 并行拉 refCount(DataApi 专用端点)与 dangling(File IPC,FS 副作用走 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 + 固定 shape:aggregation 走专用端点(依然是 DataApi),FS 副作用(DanglingCache + 冷路径 fs.stat)一律走 File IPC。消费者两次 useQuery 并行组合,成本对调用点显式可见。

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

建议FilesPage 默认同时发起列表 + refCount 两个 query,保留现有排序 UX;dangling 由独立 IPC 查询提供失效标记。冷启动首次 list 的 dangling 冷查可能有 N 次 stat(Promise.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 files 表;OrphanRefScanner 延迟启用
Paintings 迁移延后导致 painting 引用的文件被误删'painting' 不在 FileRefSourceType union 内,OrphanRefScanner 无法枚举该 sourceType;PaintingMigrator 上线时按三件套(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,纯删除

#文件改动
1src/shared/data/types/file/file.ts:27删除 tokens?: number
2src/renderer/types/file.ts:123删除 tokens?: number
3src/shared/data/types/knowledge.ts:52FileMetadataSchema 中删除 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 fields(2-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 —— 双层推导:

typescript
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 query(1 个关键点,必须迁移)

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

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

位置关键分支
src/renderer/services/TokenService.ts:22, 96, 129TEXT → 读文本内容估 token;IMAGE → 按 size/100 估 token
src/renderer/services/MessagesService.ts:145IMAGE → image block;else → file block
src/renderer/aiCore/prepareParams/fileProcessor.ts:28, 56, 69, 207, 244, 271TEXT / DOCUMENT / IMAGE 派生 AI SDK FilePart 的不同构造(内联文本 / base64 / URL)
src/renderer/aiCore/prepareParams/modelCapabilities.ts模型能力匹配(支持图像、文档等)
src/renderer/pages/home/Inputbar/context/InputbarToolsProvider.tsx:176files.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:214IMAGE 走不同编辑逻辑
src/renderer/pages/home/Messages/MessageAttachments.tsx:47type === undefined 时跳过渲染
src/renderer/pages/knowledge/items/KnowledgeVideos.tsx:112筛选 VIDEO
src/renderer/utils/messageUtils/create.ts:108, 185IMAGE / 非 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) 纯函数现算:

typescript
// 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 改动:

typescript
'/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 TEXT(OTHER 的自定义文本扩展名仍显示为 OTHER)
  • 用户体验微降:某些历史 .log/.ini 类文件若无 ext 派生规则覆盖,列表里是 OTHER。用户实际使用(打开、send to chat)时 File IPC getMetadata 会给出正确 TEXT

2.5.5 迁移步骤

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

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

Step B: DataApi 加 type 支持

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

Step C: FileManager IPC getMetadata 升级

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

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

按子系统分批:

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

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

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

Step F: Schema 清理

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

2.5.6 Buffer 升级的未决点

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

场景旧行为v2 行为评估
用户上传 foo.log(.log 不在 textExts 中)FileStorage.getFileType → OTHER → buffer 升级 TEXT → 存 TEXT上传时只存 ext 不存 type;list 展示为 OTHER;打开预览时 getMetadata 升级为 TEXT有 UX 微降:列表里是 OTHER 但预览能打开
TokenService 对 OTHER 文件估 tokenfile.type === TEXT 不匹配 → 当图片处理(size/100,错!)同样的问题——除非 TokenService 先调 getMetadata注意:这里旧行为就有 bug,v2 需要先 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.typeupgrade script 是历史代码;保留原状不执行即可(v2 migration 不回走 Dexie 升级路径)
Buffer 升级 regression测试覆盖几种典型:.log 上升为 TEXT、.bin 不升级、大文件 sample size 合理

2.5.9 执行时机

大部分改动需要 v2 FileManager / DataApi 基础设施就绪。基础 PR(A/B/C)可以在 Phase 2 完成,消费者迁移(D)贯穿 Batch A-E,Schema 清理(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)

typescript
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 保留——否则 `` 可能导致用户通过预览触发 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 渲染 ``。不能改成 async IPC(大列表下 async 会产生大量 waterfall 延迟)。

C2. 系统级 open / reveal(3 个):

位置操作
src/renderer/pages/files/FilesPage.tsx:105openPath(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, 531isTextFile / readExternal / readText
src/renderer/pages/home/Inputbar/AttachmentPreview.tsx:159isTextFile(path)
src/renderer/pages/home/Inputbar/components/InputbarCore.tsx:465readExternal(path, true) 读内联 txt
src/renderer/utils/file.ts:113isSupportedFile(path, extensionSet)

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

C4. 系统路径 interop(2 个):

位置操作
src/renderer/pages/agents/components/AgentSessionInputbar.tsx:395files.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:492getFileExtension(file.path) —— 但 file.ext 已经有
src/renderer/components/ObsidianExportDialog.tsx:110, 289fullPath 作为 key,files.find(f => f.path === value)

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

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

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

C7. Main 侧 knowledge readers(3 个):

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

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原始绝对路径 FilePathC4 agent 上下文、drag-drop、subprocess spawn;以及作为 `` URL 的输入
共享纯函数 toSafeFileUrl(path, ext)@shared/file/urlUtilfile:// URL + 危险文件 safety wrapC1 `` / <video src> 显示(在 renderer 进程内合成)

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

typescript
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 共用):

typescript
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):

typescript
// 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 侧基础函数(保留不变):

typescript
// 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集中在共享 util(main + renderer 同一份)
Null byte 安全检查renderer 需补resolvePhysicalPath 已有同左同左
DataApi 边界纪律n/a❌ 破坏✅ 遵守✅ 遵守
IPC 表面n/a0 个(塞进 DataApi)4 个(path + url 各 2)2 个(只有 path)
`` cost同步字符串拼接列表 query 内部 map 逐条独立 useQuery + IPC0 IPC(path 已有,URL 就地合成)

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

2.6.6 迁移步骤

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

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

Step B: C1 显示 URL 迁移

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

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

Step C: C2 open/reveal 迁移

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

Step D: C3 FS 内容读取迁移

#文件改动
D1TranslatePage.tsx:501, 528, 531所有 isTextFile(path) / readExternal(path, true) / readText(path)fileIpc.getMetadata(handle) / fileIpc.read(handle)
D2AttachmentPreview.tsx:159isTextFile(file.path)fileIpc.getMetadata(handle)
D3InputbarCore.tsx:465readExternal(targetPath, true)fileIpc.read(handle)
D4utils/file.ts:113isSupportedFile(file.path, ...) → 改签名接 ext(isSupportedFileExt(ext, ...)),因为真正只需要 ext

Step E: C4 Agent 上下文

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

Step F: C5 Path 派生消除

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

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

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

Step H: C7 Main 侧 knowledge readers

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

Step I: Legacy accessor 移除

#文件改动
I1FileManager.getFilePath, getSafePath, getFileUrl, getFilefile.path = ...全部删除(或留 shim 期过渡)
I2FileMetadata.path 字段最终删除
I3Dexie 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 禁区):

typescript
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 里内嵌 FileMetadataChatMigrator 抽取 file.id 建立 file_ref(sourceType='chat_message');新 message block JSON 只存 fileEntryId;渲染时查 FileEntry。不需要 shim,见 §2.6.10 Q3
Drag-drop 出 Cherry 给 OSElectron drag-drop 需要绝对路径;用 entryToAbsolutePath(entry) 获取

2.6.10 复杂度与执行时机

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

建议拆分 8-10 个 PR(Step A-I 各一):

  • Step A(helper)和 Step B-F(renderer 消费者)可 Phase 2 起步
  • Step G-H(main 侧)依赖 FileManager 实现就绪
  • Step I(legacy 清理)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: 哪些消费路径真的需要路径字符串?

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

类别需要 path?替代/说明
C1 file:// URL 显示(`` × 3 个文件)需要(renderer 直接吃路径字符串)/ <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) 给隔离的临时 path;renderer 不接触
C7 Knowledge reader(main 侧)✅ main 内部需要main 直调 resolvePhysicalPath(entry);renderer 不接触

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

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

这意味着 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 查 FileEntry;ChatMigrator 已透传 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.3(count 用 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(`` / <video src>),就地合成,无独立 IPC

为什么不再走 DataApi opt-in:DataApi 被严格收紧为纯 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(存储名)现状

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

Renderer 中的真实消费者

位置用途
src/renderer/services/FileManager.ts:148 getFileUrlfile://${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, 222XML 序列化 <file filename="${fileBlock.file.name}"> —— 传给 LLM
src/renderer/pages/knowledge/components/KnowledgeSearchItem/hooks.ts:54href: http://file/${item.file.name}
src/renderer/hooks/useKnowledge.ts:134, 138window.api.file.delete(file.name) 传存储名给删除 API

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

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

Producer

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

Consumer

位置用途
src/renderer/services/FileAction.ts:18, 19, 37tempFilesSort 识别 temp_file 前缀;sortFiles 按 name 排序
src/renderer/services/FileAction.ts:100-102rename 操作: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, 57OpenAI 上传 name: file.origin_name / displayName
src/main/services/remotefile/GeminiService.ts:38, 60, 79Gemini 上传 displayName
src/main/services/remotefile/MistralService.ts:28, 36, 47Mistral 上传 fileName / displayName
src/renderer/aiCore/prepareParams/messageConverter.ts:82, 149, 159, 161, 162AI SDK FilePart fileName;log / toast
src/renderer/services/KnowledgeService.ts:135markdown 链接 [${item.file.origin_name}](...)
src/renderer/services/ApiService.ts:473fileBlocks.map(fb => fb.file.origin_name) 列表
src/renderer/components/RichEditor/useRichEditor.ts:523alt: 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 的新字段语义

typescript
// 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 恒为 null(fe_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 均可,逻辑纯函数):

typescript
// 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)

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

Step B: Producer 改造(设值方)

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

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

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

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

#文件改动
D1FileAction.ts:18, 19origin_name.startsWith('temp_file')entry.name.startsWith('temp_file')(v2 name 无扩展名)
D2FileAction.ts:37a.origin_name.localeCompare(b.origin_name)a.name.localeCompare(b.name)entryDisplayName(a).localeCompare(entryDisplayName(b))
D3FileAction.ts:100-102 renamepopup 编辑 entry.name(不含扩展名),保存时只更新 nameext 独立
D4FileManager.formatFileName (renamed/ rewritten)重写:entry.name.includes('pasted_text') 等识别,组合 entryDisplayName;替换旧 formatFileName
D5FileManager.updateFile:151-157 自愈逻辑删除(v2 name 和 ext 分离,不需要修正)
D6OpenAIService.ts:31, 46, 57, GeminiService.ts:38, 60, 79, MistralService.ts:28, 36, 47file.origin_nameentryDisplayName(entry)
D7messageConverter.ts:82, 149, 159, 161, 162同上
D8KnowledgeService.ts:135 markdown 链接链接文本 [${entryDisplayName(entry)}](...)
D9ApiService.ts:473fileBlocks.map(fb => entryDisplayName(fb.file))
D10useRichEditor.ts:523 altalt: entryDisplayName(entry)
D11knowledge/preprocess/* 派生产物名file.origin_name.replace('.pdf', '.md') → 拆成 name: entry.name, ext: 'md'entryDisplayName 后 replace

Step E: Schema / 迁移

#文件改动
E1src/shared/data/types/file/file.ts, src/renderer/types/file.tsFileMetadata 的 name / origin_name 删除(Cleanup Batch)
E2src/renderer/databases/index.tsDexie files 索引中 name, origin_name 移除(v11+ upgrade)
E3src/shared/data/types/knowledge.ts:45FileMetadataSchemaname / origin_name 删除
E4KnowledgeMappings.hasCompleteFileMetadata不再校验 origin_name;改为校验 name(新语义)+ ext
E5FileMigrator迁移映射: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:

typescript
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' 整个被保存。

v2:rename 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 + E5(FileMigrator 映射 + schema 校验)—— 迁移基础设施
  • PR3-PR5: Step B(Producer)分 main 侧 / 预处理 / renderer 字面量三个 PR
  • PR6-PR9: Step D consumer 按子系统分 PR(FileAction / TokenService / Remote upload / aiCore & RichEditor 等)
  • PR10: Step E1-E3 cleanup schema

2.7.10 风险

风险缓解
Remote upload 的 displayName 期望含扩展名entryDisplayName 默认返回含扩展名形态
预处理 .pdf.md 硬编码替换改为拆解 ext 后独立设置新 ext,逻辑更清晰
origin_name 不含 ext(bug 数据)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: number(ms epoch)。所有消费者已经通过 dayjs 读取,dayjs() 同时接受 string 和 number,所以消费侧改动极小,主要是 Producer 改写 setter 和 Migrator 做一次类型转换。

2.8.1 现状

v2 目标 schemaFileEntry.createdAt: number(ms 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各 setter:stats.birthtime.toISOString()new Date().toISOString()
src/main/utils/file.ts:154, 325, 361new Date().toISOString() / stats.birthtime.toISOString()
src/main/knowledge/utils/directory.ts:37, 51, 74stats.birthtime.toISOString()
src/main/knowledge/preprocess/MistralPreprocessProvider.ts:181new Date().toISOString()
src/main/knowledge/preprocess/BasePreprocessProvider.ts:57processedStats.birthtime.toISOString()
src/renderer/components/Popups/VideoPopup.tsx:113renderer 字面量 new Date().toISOString()
src/renderer/pages/knowledge/items/KnowledgeFiles.tsx:116renderer 字面量

Consumer(通过 dayjs 读)

位置用法
src/renderer/services/FileManager.ts:164dayjs(file.created_at).format('YYYY-MM-DD')formatFileName 里显示 pasted_text 日期
src/renderer/services/FileAction.ts:31dayjs(a.created_at).unix() - dayjs(b.created_at).unix() — 按创建时间排序
src/renderer/pages/files/FilesPage.tsx:114, 115dayjs(file.created_at).format('MM-DD HH:mm') + dayjs(file.created_at).unix()
src/renderer/pages/home/Inputbar/tools/components/AttachmentButton.tsx:79dayjs(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 — 已有 pattern:new Date(item.created_at).getTime() 转换到 ms epoch

2.8.2 迁移步骤

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

#文件改动
A1FileStorage.ts:224, 270, 336, 361, 701, 751, 1548.toISOString().getTime()new Date().toISOString()Date.now()
A2src/main/utils/file.ts:154, 325, 361同上
A3knowledge/utils/directory.ts:37, 51, 74同上
A4knowledge/preprocess/MistralPreprocessProvider.ts:181同上
A5knowledge/preprocess/BasePreprocessProvider.ts:57processedStats.birthtime.toISOString()processedStats.birthtimeMs
A6VideoPopup.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

#文件改动
C1src/shared/data/types/file/file.ts:25, src/renderer/types/file.ts:116FileMetadata.created_at: stringnumber(或保留 string,v2 新 FileEntry 自己用 number;Cleanup Batch 删旧字段)
C2KnowledgeMappings.hasCompleteFileMetadataKnowledgeMigrator.ts:71校验改为 typeof value.created_at === 'string' || typeof value.created_at === 'number'(兼容输入)
C3FileMigrator mappingnew Date(oldFile.created_at).getTime() 转换,处理可能的无效 string
C4src/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:25):id: uuidPrimaryKeyOrdered() —— 新 entry 自动生成 UUID v7_columnHelpers.ts:26
  • Zod 校验src/shared/data/types/file/fileEntry.ts:64):FileEntryIdSchema = z.uuidv7() —— 严格要求 v7
  • 测试fileEntry.test.ts:188-199):明确断言 v4 校验失败

旧 FileMetadata ID 全部是 v4

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

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() 接受任何 UUIDz.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 之前做)

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

Step B: FileMigrator 实现

typescript
// 概念代码
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"
        ? canonicalizeAbsolutePath(oldFile.path)
        : null,
    deletedAt: null, // Dexie 没软删除字段;external 也不允许 trashed(fe_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. canonicalizeAbsolutePath(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.idChatMigrator原 id 原封写入 file_ref.fileEntryId(Q3 已定)
paintingsfiles[].idPaintingMigrator(延后,见 RFC §10.4)同上
knowledge_itemsdata.file.idKnowledgeMigrator(已有)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 第三段第一位是 4,v7 是 7)——实际上天然把新旧分开;如需时间排序按 createdAt 而非 id
未来写入路径误写入 v4uuidPrimaryKeyOrdered() 保证新 entry 是 v7;不存在其他 insert 入口(API/IPC 都走 FileManager)

2.9.5 复杂度与执行时机

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

执行时机:Schema 放宽(Step A)可随时独立 PR;FileMigrator 实现(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 的命名风格)
  • id(migrator 唯一标识)'file'
  • 执行顺序(BaseMigrator.order:建议 order = 2.7——在 AgentsMigrator (2.5) 之后、KnowledgeMigrator (3) 之前。这保证:
    • 所有引用 FileEntry 的业务 migrator(Knowledge 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 启动时一次性执行):

typescript
// 概念代码 — 抽样 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)责任 migratorsourceTyperole写入时机
message_blocks.file.id(FILE block)ChatMigrator(延后'chat_message''attachment'随 chat 域整体迁移上线(PR #15067 已显式 defer);上线 PR 同时把 'chat_message' 加入 FileRefSourceType union + 新增 createRefSchema variant + 注册 SourceTypeChecker。延后期间 'chat_message' 不在 union,OrphanRefScanner 扫不到。v1 block.file.id 已透传为 v2 ImageBlock.fileId / FileBlock.fileId(inline JSON),无数据丢失,仅缺反向索引
message_blocks.file.id(IMAGE block)ChatMigrator(延后'chat_message''image'同上
Redux paintings[].files[].idPaintingMigrator(延后'painting''asset'painting 域整体迁移上线后;上线 PR 同时把 'painting' 加入 FileRefSourceType union + 新增 createRefSchema variant + 注册 SourceTypeChecker。延后期间 'painting' 不在 union,OrphanRefScanner 扫不到(见 §2.3.9 / §6 Q7)
knowledge_items.data.file.idKnowledgeMigrator'knowledge_item''source'迁移每个 knowledge_item 时同步写(见 §6 Q9 关于现有 loadFileLookup 的去向)
AI provider upload cache(延后到 FileUploadService)本轮不迁;purpose 字段丢弃(见 §2.2)

MigrationContext 扩展(跨 migrator 数据传递)

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

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

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

业务 migrator 调用约定

typescript
// 概念代码 — 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(即 v4),external 文件未经查表写入会引用已合并掉的 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') 返回 falserecordWarning 后跳过;FileMigrator 不产生任何 file_entry。业务 migrator 后续遇到 file id 查 knownIds miss → warn-skip ref(链式安全)
单条 file 字段缺失(origin_name undefined 等)recordWarning + skip 该行;不中止迁移
canonicalizeAbsolutePath 抛出(含 null byte)严重错误:抛 MigrationFatalError 中止——v1 数据已被注入恶意路径,需人工调查
External 去重时 surviving 选择失败MigrationFatalError 中止
file_entry INSERT 违反 schema CHECK(origin/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')):

typescript
// 概念代码 — 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 记录:

typescript
{
  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 去 ext;ext ← 去前导点;旧"存储名" name 丢弃
§2.8 created_atISO → ms epoch;updatedAt 同值(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 ATranslate / Agent workspace / ExportS字段级退役(§2.6 path)1-2
Batch BPaste / 临时文件 / OCRM§2.6 path2-3
Batch CPaintingL§2.3 count2-3
Batch DKnowledgeL§2.3 count + KnowledgeMigrator 就绪2-4
Batch EMessages(attachments / 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

改造点(按依赖顺序)

#文件改动
B1src/main/services/BackupManager.ts备份产物结构扩展:除 Dexie 导出外,dump SQLite DB 文件(v2 已有的 file_entry / file_ref 表所在 DB)。建议路径 <backup>/sqlite/<db-name>.db;先 DbService.checkpoint() 把 WAL flush 到主文件再 copy
B2BackupManager 恢复路径增加 SQLite DB 还原步骤:在恢复物理文件之前,先把 backup 中的 sqlite 文件 atomically rename 到目标位置(DbService 启动前完成
B3BackupManager 版本兼容检测 backup 产物是 v1(无 sqlite 目录)还是 v2(含 sqlite 目录)。v1 backup 恢复到 v2 环境:保留 Dexie 导出,让 FileMigrator 走正常迁移路径
B4FileManager.runStartupSweeps / OrphanRefScanner在「刚完成 backup restore」标志位下首次启动时跳过 orphan sweep 一次——给 DB ↔ FS 一个对齐窗口;标志位由 BackupManager 在 restore 完成时 set,FileManager onInit 读取后清空
B5Backup 产物加 v2 marker<backup>/manifest.jsonformatVersion: 2;v1 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_ref),scanner 提早启动会看到「file_entry 但无 file_ref」状态——虽然 RFC §7.1 政策是「preserve」不删 entry,但 orphan-ref-cleanup 还是会扫——而且 §3.4.1 B4 的 backup-restore 场景也需要同样的 gate

规约

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

复杂度S(Signal 接线 + 一行 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 / uploadFiledb.files.where(...) 各处
Phase 2 启动Frozen:只读,不再接受新写入新文件全部走 v2 createInternalEntry / ensureExternalEntryBatch A 适配层 toFileMetadata 把 v2 FileEntry 投影回 Dexie 形状给尚未迁的桶 P 消费者
Phase 2 切换期同上同上各 Batch 逐步切到直接消费 FileEntrytoFileMetadata 调用点递减
Cleanup Batch表删除(Dexie v12 upgrade)n/an/a

改造点

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

关键决策

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

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

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

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

问题:v1 preload 暴露 49 个 file 相关 API(File_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 完成后立刻 deprecate;Cleanup Batch 删 preload entry + main 端 handler
被 v2 取代但调用点散落readExternal / saveBase64Image / savePastedImage / download / copyBatch C-E 期间逐步迁移到 read({encoding}) / getMetadata / createInternalEntry({source:'url'});Cleanup Batch 删
保留(v2 设计上承接、签名不变)select / selectFolder(Electron 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 API(preload 加 @deprecated JSDoc + 首次调用 console.warn 一次,避免日志洪水)
  • Cleanup Batch PR 一次性 delete preload 暴露 + 对应 IPC handler 注册

复杂度M(49 个 API 逐个 audit;多数是机械下线,少数需要先确认所有调用点都已切换)。

执行时机:各 Batch 完成时 deprecate;Cleanup 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_upload 表(RFC §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)FileEntry(v2),但 BaseFileService API 接口不变
Phase 2 Batch B(AI Core)fileProcessor.ts / messageConverter.ts 切到 v2 API;remotefile/* services 维持不变同上
Phase X(FileUploadService 落地)FileUploadService.ensureUploaded 接管;remotefile/* services 标 @deprecatedFileEntry + 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.1Backup / Restore 协调🔴 高Phase 2 同步(不能等 Cleanup Batch)与 FileMigrator 同期或紧随
3.4.2OrphanRefScanner gate🔴 高与 FileMigrator 同 PR同 FileMigrator PR
3.4.3Dexie files 表 phasing🟡 中Phase 2 启动 prerequisiteBatch A 之前
3.4.4preload API 下线🟢 低各 Batch deprecate;Cleanup Batch 删除散落 + Cleanup Batch PR
3.4.5remotefile/* 过渡期🟢 低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 桶永远清不掉
  • 反向 shim(FileMetadata → FileEntry)——不提供;FileInfo → FileEntry 必须走 FileManager sanctioned 生产入口(brand 强制),伪造的 FileEntry 会被 Zod 拒收

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

typescript
// 概念代码。仅在未迁移的桶 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 消费者逐个迁移到 FileEntry,shim 调用点递减
  • 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 落地
M2Shim 双向适配实现(§4)FileEntry schema 稳定(已完成)
M3字段级退役全部完成(§2.3-§2.9)每个字段一个独立 PR
M4Batch A 完成M2 + §2.6 path
M5Batch B-D 完成M4
M6Batch E 完成M5 + 所有字段退役
M7Cleanup:删 Dexie files 表 / FileStorage.ts / FileMetadata 类型M6

5.3 每个 PR 的最小描述模板

切换 PR 应在描述中包含:

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

6. 开放问题与决策追踪

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

附录 A:术语对照

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

附录 B:修订记录

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