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.
171 KiB
File Manager Migration Plan
本文档覆盖:把旧
FileMetadata/FileStorage栈迁移到 v2 类型系统的具体执行计划。框架:v2 把旧
FileMetadata同时承担的两个角色(DB 行 + 通用文件描述符)显式拆分:
- 持久化角色 →
FileEntry(src/shared/data/types/file/fileEntry.ts)- 描述符角色 →
FileInfo(src/shared/file/types/info.ts)- 跨边界引用 →
FileHandle(src/shared/file/types/handle.ts)本文档以字段级退役(字段归 FileEntry / FileInfo / 彻底删除)和消费域切换(消费者按 P/I/A 桶归属分流)两条主线组织。
不在本文档范围:
- 目标设计(schema、API、架构原则):见
rfc-file-manager.md- FS 用法现状(main process 直接
fs调用清单):见fs-usage-audit.md- FileMetadata 消费现状(96 个文件的全量审计):见
filemetadata-consumer-audit.md- 架构语义:见
docs/references/file/architecture.md/file-manager-architecture.md本文档与 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 |
带 id、origin、deletedAt;有 lifecycle;Zod brand 强制走 sanctioned 生产路径 |
| 磁盘描述符 / 临时传参 | FileInfo |
带 path、modifiedAt;live view;任意构造 |
| 跨边界引用(两者通用) | FileHandle |
tagged 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(或其中的 FileEntryId) |
databases/index.ts Dexie 表、KnowledgeMigrator、ImageMessageBlock.file |
| I 描述符 | 只用 path / name / size / ext / type 驱动一次处理,不持久化任何身份 | → FileInfo | OCR(SupportedOcrFile、TesseractService)、TokenService、isSupportedFile |
| A 两栖 | 同一处既持久化又 pass-through,或者签名要完整 FileMetadata 但实际只用子集("接口说谎") |
→ 拆签名:持久化路径接 FileEntry,处理路径接 FileInfo | services/FileManager.ts:addFile/uploadFile、KnowledgeService 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 | 📋 计划完成 |
type |
FileInfo-only | FileInfo.type(ext 派生,默认)+ ops.getMetadata 的 PhysicalFileMetadata.type(按需);FileEntry 不存此列 |
§2.5 | 📋 计划完成 |
path |
FileInfo-only | FileInfo.path(unmanaged 身份);managed 侧由 resolvePhysicalPath(entry) 动态派生,FileEntry 无 path 列 |
§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: number(ms epoch),dayjs 天然兼容;FileInfo 走 modifiedAt(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.size 是 fs.stat 实时 |
— | N/A |
图例:📋 = 调研完成且方案清晰;🔍 = 仅列点,待单独深入调研。
2.2 purpose 字段
FileMetadata.purpose?: OpenAI.FilePurpose 在旧模型里挂在"文件"上,但实际上它是一次上传调用的参数,不是文件本身的属性。v2 FileEntry 不保留此字段。
现状调研
生产方(0 个稳定 setter):
- renderer
FileManager.ts/ mainFileStorage.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:35—purpose: file.purpose || 'assistants'传给client.files.createsrc/renderer/aiCore/prepareParams/fileProcessor.ts:141-143— 校验远端已上传文件的 purpose 和本地file.purpose是否一致(不一致抛错重传)
Schema 化石:
src/shared/data/types/knowledge.ts:53把purpose塞进KnowledgeFileItem,但 Knowledge 业务代码 零消费
迁移目标
把 purpose 从"文件属性"改为"upload 调用参数",符合以下两条原则:
- FileEntry 只描述文件本身,不绑定某一次 upload 行为
- 未来
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 bridge(window.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 §9),purpose 可作为 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 投影层被丢弃,影响是双重静默的,外部观察不到错误:
-
OpenAIService.ts:35—purpose: file.purpose || 'assistants'file.purpose为 undefined 时静默回退到'assistants'- 调用 qwen-long / qwen-doc 的文件被当成 assistant 文件上传,OpenAI API 不会报错
- 后果:远端解析方式与本地预期不一致,模型回答可能无声地降级
-
fileProcessor.ts:141-143—remoteFile.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 剥离,直接丢弃了 purpose 与 tokens 字段。但当时 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 threadPRRT_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 不再接触此字段:
// 修订后的 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/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 比较职责也挪入 service,fileProcessor 完全不接触 purpose 概念 |
步骤 #3 / #5 / #6 不变。原则一句话概括:fileProcessor 看不到 purpose,service 内部黑盒处理。
2.3 count 字段
决策:
v2 不持久化 count。
file_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 schema(src/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 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 → decrement;else → 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 删除按钮的后端。流程:
FileManager.deleteFile(fileId, true)— 不看 count,直接物理删db.message_blocks.where('file.id').equals(fileId).toArray()— 手动扫关联 blocks- 遍历 topics 重建
messages[].blocks[]去除引用 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:103 的 hasCompleteFileMetadata 校验要求 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 不进入 fileEntryTable(src/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_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[].id |
sourceType='painting', sourceId=paintingId, role='asset' |
| Knowledge items(KnowledgeMigrator 已处理) | 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 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 消费改造
按依赖顺序:
| # | 文件 / 位置 | 改动 |
|---|---|---|
| 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. 并行拉 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 都不写tokenssrc/renderer/services/FileManager.ts的 addFile / uploadFile / addBase64File 都不写src/main/utils/file.ts不写- 任何
MistralPreprocessProvider/VideoPopup/KnowledgeFiles.tsx的字面量构造都没有tokens:赋值
消费方:0 个
src/renderer/services/TokenService.ts的estimateImageTokens(file)用的是file.size / 100,不读file.tokensestimateTextTokens(text)直接从文本内容算(tokenxlib),不碰 FileMetadata- UI 没有任何位置展示
file.tokens - 业务逻辑没有任何地方读
file.tokens
Dexie schema:src/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:27—tokens?: numbersrc/renderer/types/file.ts:123—tokens?: numbersrc/shared/data/types/knowledge.ts:52—tokens: 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 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 —— 双层推导:
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:106getFileType(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—— 字面量硬编码 VIDEOsrc/renderer/pages/knowledge/items/KnowledgeFiles.tsx:113—— 字面量构造src/main/knowledge/preprocess/MistralPreprocessProvider.ts:185—— 预处理产物构造
Dexie schema:files: '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: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 → 读文本内容估 token;IMAGE → 按 size/100 估 token |
src/renderer/services/MessagesService.ts:145 |
IMAGE → image block;else → 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:140isImageFileMetadata(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 已经没有 type 列(src/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.ts的getFileType(path)实现:从 path 提取 ext → 调 shared 的getFileType(ext)- 零 FS IO
Buffer 升级(保留但收窄):
src/main/file/ops/metadata.ts的isTextFile(path)实现:复用chardet+isbinaryfile逻辑- 只在
FileManager.getMetadata(handle)时触发——打开文件 / 预览 / 处理路径上;list 查询不触发 - 旧 FileStorage 在创建时做 buffer 升级;v2 在读取时做。语义微调:文件的 type 不再是"持久化属性"而是"每次 getMetadata 现算的派生"
2.5.4 DataApi 的 type filter(无 includeType opt-in)
FilesPage 的 where('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 TEXT(OTHER 的自定义文本扩展名仍显示为 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(新) |
把 fileTypeMap 和 getFileType(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 handler(src/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 === TEXT → getFileType(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 时不写 type(schema 已无此列);旧 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 索引去掉 type(Dexie 升级) |
| 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 在创建时触发 upgrade;v2 改为 getMetadata 在读取时触发。这个语义变更的影响:
| 场景 | 旧行为 | v2 行为 | 评估 |
|---|---|---|---|
用户上传 foo.log(.log 不在 textExts 中) |
FileStorage.getFileType → OTHER → buffer 升级 TEXT → 存 TEXT | 上传时只存 ext 不存 type;list 展示为 OTHER;打开预览时 getMetadata 升级为 TEXT | 有 UX 微降:列表里是 OTHER 但预览能打开 |
| TokenService 对 OTHER 文件估 token | file.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 === X → getFileType(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
getMetadatabuffer 升级(独立 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 基础设施就绪。基础 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-89 的 getFile(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 / reveal(3 个):
| 位置 | 操作 |
|---|---|
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. 系统路径 interop(2 个):
| 位置 | 操作 |
|---|---|
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 作为 key,files.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 readers(3 个):
| 位置 | 操作 |
|---|---|
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 不走 IPC:file:// URL 是对已有 path 做纯字符串格式化 + 危险扩展名的策略判断。两者都可以放在共享包里作为纯函数暴露:
- Authority 仍在 main ——
resolvePhysicalPath定义"id + ext 如何拼接、userData 在哪、是否 hash-bucket" - 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/a | 0 个(塞进 DataApi) | 4 个(path + url 各 2) | 2 个(只有 path) |
<img src> cost |
同步字符串拼接 | 列表 query 内部 map 逐条 | 独立 useQuery + IPC |
0 IPC(path 已有,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 handler(FileManager) | 实现 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 消费者组合两个 useQuery:DataApi 拉 entry 列表(固定 shape,带 ext)+ 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, ...) → 改签名接 ext(isSupportedFileExt(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.id 或 entry.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:17 和 OcrExampleApiClient.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.path → resolvePhysicalPath(entry)(main 直调) |
| H2 | knowledge/embedjs/loader/index.ts:60, 78 |
同上 |
Step I: Legacy accessor 移除
| # | 文件 | 改动 |
|---|---|---|
| I1 | FileManager.getFilePath, getSafePath, getFileUrl, getFile 的 file.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_ref(sourceType='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 |
| 复杂度 | 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 自身区分 entry 与 path 两种引用形态,旧 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 显示(<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) 给隔离的临时 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: string(ImageBlock.fileId/FileBlock.fileId),渲染时通过 id 查 FileEntry;ChatMigrator 已透传 v1block.file.id→ v2fileId。不需要 shim 反推 path——block JSON 里连 path 都没了。 - ⏳ 延后:
file_ref反向索引行的写入随 chat 域整体迁移到 v2 file_ref 服务时一并上线(同步注册'chat_message'sourceType;见 §2.10.3 表格里 ChatMigrator 行的延后说明)。延后期间,附件可达性由 inlinefileId维持,仅 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(<img src>/<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 / entryToAbsolutePathhelper(暴露存储布局给 renderer) - DataApi
includePath/includeUrlopt-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 + ext 或 path.basename(...) 模式,统一是"id + ext"形态。由 createInternalEntry / ensureExternalEntry 返回给 renderer。
Renderer 中的真实消费者:
| 位置 | 用途 |
|---|---|
src/renderer/services/FileManager.ts:148 getFileUrl |
file://${filesPath}/${file.name} 构造 URL——但 getFileUrl 本身很少被直接调用,多数地方用 getFilePath 或 getSafePath(已在 §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(非 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, 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 fileName;log / 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 恒为 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 均可,逻辑纯函数):
// 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 写不含扩展名的 basename;ext 写不含前导点的扩展名;不再写 storage name name=id+ext(storage 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(不含扩展名),保存时只更新 name;ext 独立 |
| 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_name → entryDisplayName(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 |
FileMetadataSchema 中 name / origin_name 删除 |
| E4 | KnowledgeMappings.hasCompleteFileMetadata |
不再校验 origin_name;改为校验 name(新语义)+ ext |
| E5 | FileMigrator |
迁移映射:origin_name → 拆出 name 和 ext;旧 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' 整个被保存。
v2:rename popup 只编辑 name,ext 不变。用户看到输入框里是 'My New Doc'(无扩展名),保存后 entry.name = 'My New Doc',ext='pdf' 保留。
UX 注意:
- Rename popup 的输入框 placeholder / tip 应该提示"不含扩展名"
- 对于 internal 文件:保存
name只是 DB 更新,物理文件路径({id}.{ext})不变 - 对于 external 文件:物理 rename 需要重新组合
{newName}.{ext},renameexternalPath
这个逻辑在 §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 |
formatFileName 的 pasted_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 目标 schema:FileEntry.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, 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.hasCompleteFileMetadata、KnowledgeMigrator.ts:71—typeof 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 替换)
| # | 文件 | 改动 |
|---|---|---|
| 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: string → number(或保留 string,v2 新 FileEntry 自己用 number;Cleanup Batch 删旧字段) |
| C2 | KnowledgeMappings.hasCompleteFileMetadata、KnowledgeMigrator.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_at做typeof分支兜底,所有不合法值回退到Date.now()
执行时机:独立小 PR,与 §2.4 tokens 类似,可以打包到"字段清理"合并 PR,甚至和 ext 前导点迁移合并。
2.9 id: UUID v4 → UUID v7 迁移
决策:
保留原 v4 ID;新 entry 自动用 v7。放宽
FileEntryIdSchema为z.uuid()接受两种。跨表引用零翻译。
2.9.1 现状
v2 设计(当前代码):
- DB schema(
src/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, 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 原封不动
- 放宽
FileEntryIdSchema从z.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 的理由:
- v7 的核心优势是"新 insert 按时间顺序",对索引 B-tree 友好;历史数据已经插入完成,v7 优势对它们不适用
- 跨表 ID 翻译极易出 bug(每个引用源都要改,一个漏掉就是 orphan)
z.uuid()仍然能拦住 garbage 字符串(真正的 schema validation 目的)- 符合
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(不再 fail);v7 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 也不允许 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 必须:
- 按
canonicalizeExternalPath(path)分组 - 每组保留一条(建议取
createdAt最早的)作为 surviving row - 把组内其他 id 收集到 id-remap 表,在迁移
file_ref(以及其他引用 FileMetadata.id 的业务表)时将旧 id 重路由到 surviving id - 被合并掉的 FileMetadata.id 不产生
file_entry行
这条 invariant 用 schema 层的 UNIQUE index 强制,任何漏网的重复插入都会在 migrator 内抛出并中止迁移——这是期望行为,引导开发者补全 remap 逻辑而不是让脏数据进库。
Step C: 引用表迁移(对应各自 Migrator)
| 引用表 | 对应 Migrator | 改动 |
|---|---|---|
message_blocks 的 file.id |
ChatMigrator | 原 id 原封写入 file_ref.fileEntryId(Q3 已定) |
paintings 的 files[].id |
PaintingMigrator(延后,见 RFC §10.4) | 同上 |
knowledge_items 的 data.file.id |
KnowledgeMigrator(已有) | FileItemData.file.id 保持原值 |
| file_ref 表(迁移时新建) | FileMigrator / 各引用源 Migrator | 所有 fileEntryId 用原 v4 id |
无跨表 ID 翻译——这是 Option A 的主要简化点。
2.9.4 风险
| 风险 | 缓解 |
|---|---|
| Renderer 或其他校验点期望严格 v7 | 全仓 grep FileEntryIdSchema、z.uuidv7() 使用;除 fileEntry.ts 外应该没有其他场景专门依赖 v7 形态 |
| v7 time-ordering 混入 v4 导致"排序不连续" | 历史 entry 永远排在 v7 前面(v4 第三段第一位是 4,v7 是 7)——实际上天然把新旧分开;如需时间排序按 createdAt 而非 id |
| 未来写入路径误写入 v4 | uuidPrimaryKeyOrdered() 保证新 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 Dexiefiles表,经 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.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[].id |
PaintingMigrator(延后) | 'painting' |
'asset' |
painting 域整体迁移上线后;上线 PR 同时把 'painting' 加入 FileRefSourceType union + 新增 createRefSchema variant + 注册 SourceTypeChecker。延后期间 'painting' 不在 union,OrphanRefScanner 扫不到(见 §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 ...
}
关键约定:
- 任何业务 migrator 在写入引用 fileEntryId 之前必须查
idRemap——这是 surviving id 的唯一来源;internal 文件查表 miss 时退回原 id(即 v4),external 文件未经查表写入会引用已合并掉的 loser id fileRefService.create(或等价db.insert(fileRef))在迁移期使用onConflictDoNothing语义——避免重跑时触发UNIQUE(fileEntryId, sourceType, sourceId, role)冲突- 单条 ref 写入失败 →
recordWarning跳过;不整批回滚。理由:v1count本就是启发式数字,少几行 ref 不破坏迁移完整性;后台OrphanRefScanner兜底——漏建的 ref 体现为file_entry"零引用",由 cleanup UI 展示给用户 fileEntryId在knownIds缺失(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 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')):
// 概念代码 — 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 |
仅用于判定 origin(isInternalPath(path) → internal;否则 external + canonicalize 后存入 externalPath) |
§2.7 name / origin_name |
拆分:name ← origin_name 去 ext;ext ← 去前导点;旧"存储名" name 丢弃 |
§2.8 created_at |
ISO → 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 拆分顺序:
- §2.9.3 Step A ——
FileEntryIdSchema放宽至z.uuid()(独立 PR,可先行) MigrationContext.sharedData协议扩展(添加fileMigrator.idRemap/fileMigrator.knownIds的 key 与读写工具,建议封装成getFileMigratorProducts(ctx)helper 避免各 migrator 自己 castunknown)- FileMigrator 本体(含 §2.10.2 抽样验证 + §2.10.3 idRemap 计算 + §2.10.4 失败处理 + §2.10.5 日志记录)
- KnowledgeMigrator 改造(决议见 §6 Q9 后落地:在现有
data.file.id处理流程后追加 file_ref 写入;loadFileLookup去留按 Q9 结论) - ChatMigrator 改造(延后,PR #15067 已 defer;与 chat 域 file_ref 服务同 PR 上线,三表面同步——
allSourceTypes增加'chat_message'、createRefSchema变体、OrphanRefScannerchecker) - 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 | Messages(attachments / images) | XL | 所有字段级退役完成 | 3-5 |
顺序依据:
- 从小到大,先在低风险域验证适配层设计
- Messages 最后——它的数据模型最深,且量最大
- 字段级退役与域切换交错:某域阻塞的字段先做退役,再做域切换
3.2 每个域的切换模板
每个域的切换 PR 遵循相同步骤,逐个域展开时填入具体内容:
- 入口枚举:列出该域触发文件操作的所有 UI 入口(哪个 component)
- 当前数据结构:该域如何持有 FileMetadata(直接内嵌?数组?hashmap?)
- 当前 API 调用:调
window.api.file.*的哪几个方法 - 新 API 映射:每个旧调用对应到 v2 哪个 IPC / DataApi
- 数据迁移器:如果该域有 Dexie → SQLite 数据搬运,对应的 Migrator 位置
- UI / 行为变化:是否有用户可感知的行为差异(例如 dangling 文件展示)
- 测试点:关键集成测试用例
- 回滚策略:如果发现严重问题,如何短时间回退
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 删除,SQLitefile_entry/file_ref进场 - 备份产物必须同时容纳:物理文件(位置不变)+ SQLite DB 文件 + 兼容期内仍存在的 Dexie 导出
- 恢复时必须原子化还原物理文件 + SQLite DB,否则会触发:
- 物理文件还原 + DB 未还原 → 启动期 orphan-file-sweep 误删用户文件(命中 §10.4 安全阈值时只是不删,但
count-fraction触发会变成日常发生) - DB 还原 + 物理文件未还原 →
file_entry行指向不存在物理文件,全部变 dangling
- 物理文件还原 + DB 未还原 → 启动期 orphan-file-sweep 误删用户文件(命中 §10.4 安全阈值时只是不删,但
改造点(按依赖顺序):
| # | 文件 | 改动 |
|---|---|---|
| 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 完成时 set,FileManager onInit 读取后清空 |
| B5 | Backup 产物加 v2 marker | <backup>/manifest.json 写 formatVersion: 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 在
Backgroundphase 启动,扫描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
规约:
| # | 改动 |
|---|---|
| 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 |
复杂度: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 / uploadFile 等 |
db.files.where(...) 各处 |
| Phase 2 启动 | Frozen:只读,不再接受新写入 | 新文件全部走 v2 createInternalEntry / ensureExternalEntry |
Batch A 适配层 toFileMetadata 把 v2 FileEntry 投影回 Dexie 形状给尚未迁的桶 P 消费者 |
| Phase 2 切换期 | 同上 | 同上 | 各 Batch 逐步切到直接消费 FileEntry,toFileMetadata 调用点递减 |
| Cleanup Batch | 表删除(Dexie v12 upgrade) | n/a | n/a |
改造点:
| # | 改动 |
|---|---|
| D1 | Phase 2 启动 PR:renderer 端所有 db.files.put / add / update / delete 调用点改抛 DexieFilesFrozenError;保留 db.files.get / toArray / where 仅供 toFileMetadata 适配 |
| D2 | useFiles / 旧 FileManager.uploadFile 等业务入口改走 v2 IPC,写入产生 FileEntry,不回写 Dexie |
| D3 | Cleanup 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 / copy |
Batch 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 加
@deprecatedJSDoc + 首次调用console.warn一次,避免日志洪水) - Cleanup Batch PR 一次性
deletepreload 暴露 + 对应 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在上传前查 cachedfileId,命中则跳过
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 标 @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 deprecate;Cleanup 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 桶永远清不掉- 反向 shim(FileMetadata → 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 直接替换成调用createInternalEntryIPC- 若仅需要用文件属性(OCR / TokenService / 渲染等):这是桶 I 或桶 A 的处理路径——
FileMetadata瘦身成FileInfo,签名改掉就好
4.3 Shim 的生命周期
- Phase 2 双读双写期:引入
toFileMetadata(仅桶 P 用) - Batch A-E consumer migration 期:随桶 P 消费者逐个迁移到
FileEntry,shim 调用点递减 - Cleanup Batch:最后删掉
toFileMetadata与FileMetadata类型本身;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 加 Q9(KnowledgeMigrator loadFileLookup 在新协议下的去留) |
| 2026-05-11 | 0.3 | 新增 §3.4 跨模块切换协调(Backup-Restore 协调 / OrphanRefScanner 启动 gate / Dexie files 表 phasing / v1 window.api.file.* 下线顺序 / remotefile/* services 过渡期);与 RFC §13 同步勾掉对应条目 |