v2-refactor-temp/docs/file-manager/migration-plan.md
本文档覆盖:把旧
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 的展开。
| 主线 | 含义 | 本文档对应章节 |
|---|---|---|
| 字段级退役 | 旧 FileMetadata 的每个字段"归到 FileEntry / 归到 FileInfo / 彻底删除"的决策与执行 | §2 |
| 消费域切换 | 按业务域(messages / knowledge / painting / ...)把 renderer 侧旧 API 调用换到 v2;消费者先分 P/I/A 桶,再确定迁移到 FileEntry / FileInfo / 拆签名 | §3 |
这两条可以并行推进,字段退役为域切换扫清障碍(减少 shim 期的适配面积)。
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)。
审计(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 |
关键规则:
FileEntry(或对只需要 id 的场合用 FileEntryId),按业务域切分 PRFileInfo,不经过 FileEntry 中间层——它们从未需要身份,shim 期也不需要适配createInternalEntry / ensureExternalEntry,处理动作接 FileInfoFileInfo → FileEntry 必须走 FileManager 的 sanctioned 生产入口,不提供隐式 converter原先打算的"双向 shim(FileEntry ↔ FileMetadata)"被这个拆分压缩了适用范围:
FileMetadata 改吃 FileInfo 只是字段瘦身(丢 id / count / origin_name 这类身份字段),没有跨系统 boundary,直接改签名即可FileEntry 投影回旧 FileMetadata 形状给尚未迁移的持久化消费者;反向(旧 FileMetadata 读出当 FileEntry 用)仅在兼容旧 Dexie 数据窗口期需要See §4 for the shim function specifications.
filemetadata-consumer-audit.md ──引用──▶ migration-plan.md
(完整现状快照 96 files + P/I/A 桶标签) (落地计划)
│
└── 每个字段 / 每个域都在 audit 里有具体 file:line
本文档只重复"结论 + 动作",不重复原始引用清单
对每个字段 / 域的迁移条目,先到 audit 查清楚现状,再写入本文档。写入时只保留动作相关的少量引用(具体行号),深度引用留在 audit。
归宿规则(按 §1.2.1 的角色拆分):
- 身份 / 持久化相关字段 →
FileEntry(可能伴随重命名或格式变化)- 磁盘描述符相关字段 →
FileInfo- 业务特化 / 死字段 / 本来就不该在文件对象上的字段 → 彻底删除,归到各自合适的地方(upload 调用参数、
file_ref聚合、TokenService 内部 cache 等)本章每个字段按"属于哪个角色 / 如何退役"展开。
下表给出每个旧 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 |
图例:📋 = 调研完成且方案清晰;🔍 = 仅列点,待单独深入调研。
purpose 字段FileMetadata.purpose?: OpenAI.FilePurpose 在旧模型里挂在"文件"上,但实际上它是一次上传调用的参数,不是文件本身的属性。v2 FileEntry 不保留此字段。
生产方(0 个稳定 setter):
FileManager.ts / main FileStorage.ts 创建 FileMetadata 时不写入 purpose——数据库中 99% 实例该字段为 undefinedsrc/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 调用参数",符合以下两条原则:
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 文件管理重构之前或之后均可:
FileEntry 天然不需要为此字段做任何决策;qwen-long 的 'file-extract' 行为在 upload 调用点清晰体现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)),比旧模型单一字段准确得多。
purpose 字段一旦在 v1 ↔ v2 投影层被丢弃,影响是双重静默的,外部观察不到错误:
OpenAIService.ts:35 — purpose: file.purpose || 'assistants'
file.purpose 为 undefined 时静默回退到 'assistants'fileProcessor.ts:141-143 — remoteFile.purpose !== file.purpose 校验
file.purpose === undefined ≠ 任何非空远端 purpose → 永远 mismatch → 抛 "File purpose mismatch" 重传两条路径都是 silent failure(前者 fallback、后者重传),生产环境只会以"模型答非所问"或"API quota 异常上涨"间接显现,CI 测试很难触发。结论:在迁移步骤 #1–#6 全部落地之前,任何 v1 ↔ v2 投影层(shim、adapter、序列化器)都必须把 purpose 列入显式透传清单,不能默认依赖 schema 不变。
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 不再可触发。
教训:
FileEntry → FileMetadata 适配代码时,应该先核对 §2.2 现状调研里所有消费方都已迁移完毕;这次漏掉就是因为只看了一处(self-review audit 仅列 fileProcessor,漏了 OpenAIService —— 详见 PR #15067 thread PRRT_kwDOL_2xws6EeQIz)fileProcessor延伸自上述 Batch 0 回顾的更深层观察 —— purpose 的赋值动作本身就不该出现在 fileProcessor 这一层:
fileProcessor 是"AI 调用参数准备"层,它的职责是把 file metadata 组合成 LLM 请求体purpose 是 OpenAI Files API 的 provider-specific 概念,与 LLM 请求体本身无关'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 内部黑盒处理。
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 = ? 取代,完全按需计算。
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 前) |
所有 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 |
| 路径 | 语义 |
|---|---|
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)) | 同上语义 |
以下业务在业务对象被删除时清理 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 的各入口 |
src/renderer/services/FileAction.ts:45-94 (handleDelete) 是 FilesPage 删除按钮的后端。流程:
FileManager.deleteFile(fileId, true) — 不看 count,直接物理删db.message_blocks.where('file.id').equals(fileId).toArray() — 手动扫关联 blocksmessages[].blocks[] 去除引用db.message_blocks.bulkDelete(blockIdsToDelete)这条路径绕开了 count 机制,揭示旧架构没有真正的引用完整性保证——count 只是个启发式数字,真正的引用扫描发生在 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 信息) |
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 中化石化保留。
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
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 取代) |
旧语义:FileManager.deleteFile(id, force=false) 在 count === 1 时立刻物理删除文件。
v2 两种选择:
file_ref 最后一行被删时,trigger FileManager 立即 permanentDelete(fileEntryId)。需要数据库 trigger 或业务层额外一步OrphanRefScanner 定期扫描 zero-ref 文件做清理。UX 变化:刚解除最后一个引用后,文件不会立刻消失(但也不再占业务列表)推荐选项 2,理由:
deletedAt 软删的哲学一致(延迟、可逆);external 虽然没有软删状态,但延迟 orphan 清理仍然提供"重新引用不失败"的好处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 副作用。
此字段迁移强绑定域级迁移,不能独立小 PR 完成(不像 purpose):
FileAction.handleDelete 手动扫 message_blocks 的逻辑在 Batch E 中会被重写因此 count 字段的完整退役贯穿 Phase 2 到 Cleanup Batch,无独立时间点。
| 风险 | 缓解 |
|---|---|
| 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 端点;用户反馈驱动是否改默认组合 |
tokens? 字段结论:这是一个100% 死字段——从未写入、从未读取。直接删除,零功能影响。
生产方: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) 直接从文本内容算(tokenx lib),不碰 FileMetadatafile.tokensfile.tokensDexie 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 里v2 FileEntry 不保留此字段。token 估算是 TokenService 的职责(消息构造期临时计算),不是文件的属性——这和 purpose 同样的设计原则。
独立小 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 改动,无业务逻辑改动。
不需要在 FileEntry 上挂字段。如果未来 TokenService 发现 estimateTextTokens 对大文件开销大,可以:
Map<contentHash, number> — content hash 作为 key(已经由 ops.contentHash 提供),避免相同内容重复算token_estimate_cache,和 FileEntry 表解耦这不是 Phase 1 范围。先删掉现有 schema 上的死字段,有需要再按实际 profiling 数据加缓存。
独立 PR,随时可做。建议和 purpose 字段退役一起打包:refactor(file): drop unused FileMetadata.tokens and .purpose fields(2-3 个文件改动,纯删)。
几乎零风险。唯一可能的"惊喜"是外部消费方(非主项目代码)读了这个字段——但本仓的 FileMetadata 不作为公开 API 导出,所以这不是问题。
type: FileType 字段定性:type: 'image' | 'video' | 'audio' | 'text' | 'document' | 'other' 从持久化字段变为动态推导。主路径:getFileType(ext) 按扩展名映射;冷路径:isTextFile(path) buffer 探测升级 OTHER → TEXT。
生产方(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
}
src/main/utils/file.ts:106 getFileType(ext) → 查 fileTypeMap(纯函数)。映射表见 file.ts:20-28 的 imageExts / videoExts / audioExts / textExts / documentExtsFileStorage._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')
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:140 isImageFileMetadata(file) => file.type === FILE_TYPE.IMAGED. Dexie upgrade migrator:
src/renderer/databases/upgrades.ts:188 —— 历史 Dexie 升级脚本用 file.type === IMAGEE. Migration 校验:
KnowledgeMappings.hasCompleteFileMetadata 要求 typeof value.type === 'string'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)Buffer 升级(保留但收窄):
src/main/file/ops/metadata.ts 的 isTextFile(path) 实现:复用 chardet + isbinaryfile 逻辑FileManager.getMetadata(handle) 时触发——打开文件 / 预览 / 处理路径上;list 查询不触发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 等价近似)
}
}
}
优点:
getFileType(ext) 本身是共享纯函数,renderer 端零成本调用代价:
getMetadata 会给出正确 TEXTStep 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' 检查删除 |
旧 _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 等"配置/日志"类常见文本| 维度 | 评估 |
|---|---|
| 触达文件数 | 32 + schema 3 |
| 改造性质 | 多数是"表达式替换"(entry.type === X → getFileType(entry.ext) === X),少量有逻辑调整(getMetadata 调用、DataApi query) |
| 最难点 | FilesPage Dexie filter 迁移 + TokenService 的 buffer 升级调用 |
| 复杂度 | L |
建议拆分:
getFileType + ops.getFileType + ops.isTextFile 实现(独立基础设施)type / includeType(独立 API 扩展)getMetadata buffer 升级(独立 IPC 扩展)| 风险 | 缓解 |
|---|---|
| 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 合理 |
大部分改动需要 v2 FileManager / DataApi 基础设施就绪。基础 PR(A/B/C)可以在 Phase 2 完成,消费者迁移(D)贯穿 Batch A-E,Schema 清理(F)在 Cleanup Batch。
path 字段定性:path 是最深的单一依赖。但调研显示旧架构里 path 事实上已经是派生字段——DB 里的 path 列在每次读取时被覆盖,真实 SoT 是 id + ext + userData 的运行时拼接。v2 只需要把"约定式派生"变成"显式 API 派生"。
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 都覆盖为运行时计算值。也就是:
{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。
| 访问器 | 位置 | 语义 |
|---|---|---|
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 脚本。
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: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。
不引入新 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)) |
原则: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;以及作为 `` URL 的输入 |
共享纯函数 toSafeFileUrl(path, ext)(@shared/file/urlUtil) | file:// URL + 危险文件 safety wrap | C1 `` / <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 做纯字符串格式化 + 危险扩展名的策略判断。两者都可以放在共享包里作为纯函数暴露:
resolvePhysicalPath 定义"id + ext 如何拼接、userData 在哪、是否 hash-bucket"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 格式化放到共享纯函数:
toSafeFileUrl 合成收益(相比 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) |
| `` cost | 同步字符串拼接 | 列表 query 内部 map 逐条 | 独立 useQuery + IPC | 0 IPC(path 已有,URL 就地合成) |
Renderer helper 方案、DataApi opt-in 方案、IPC 双方法方案皆已作废。
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 字段 | 删除 |
readExternal 语义(已统一到 read)window.api.file.readExternal(path, asText) 是旧 API。v2 统一到 FileIpcApi.read(handle),不保留别名:
readExternal(path, true) → read({ kind: 'path', path }, { encoding: 'text' })read({ kind: 'entry', entryId }, ...)所有调用方必须替换(不存在过渡兼容层)。
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 里。
| 风险 | 缓解 |
|---|---|
| 危险文件防护丢失 | 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) 获取 |
| 维度 | 评估 |
|---|---|
| 触达文件数 | ~20 + main side 5 |
| 改造性质 | 大多数是"调用 API 替换",不是数据模型改动 |
| 最大复杂点 | 旧 FileMetadata → FileEntry 的适配层(shim 期);危险文件防护的迁移;OCR providers 改 withTempCopy |
| 复杂度 | L–XL(比 type 重,因为触达了 UI 渲染链路 + main 侧多个子系统) |
建议拆分 8-10 个 PR(Step A-I 各一):
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 显示(`` × 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 场景真的需要路径字符串。其中:
这意味着 renderer 侧的 path helper 是必要但最小的:仅为这两个场景存在,不做其他扩展。
Q3: 历史 message block 里内嵌 FileMetadata 的兼容方式 ✅ 已决定(终态;落地分两步)
终态:历史 message blocks 的 file: FileMetadata 字段在 v2 消息模型里不再内嵌文件对象,而是通过 file_ref 表建立关系(sourceType='chat_message', sourceId=messageId, fileEntryId=..., role='attachment' | 'image')。
落地拆分:
fileEntryId: string(ImageBlock.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。
已作废的设计:
entryToFileUrl / entryToAbsolutePath helper(暴露存储布局给 renderer)includePath / includeUrl opt-in 字段(把 main-side 计算混入 SQL 接口)收益:
useQuery,藏不住Promise.all)name / origin_name 字段定性:两个字段语义完全不同,需要分别处理。
| 旧字段 | 旧语义 | v2 归属 |
|---|---|---|
name | 存储名 = {id}.{ext}(文件系统上的文件名) | 删除(由 resolvePhysicalPath(entry) 派生 {id}.{ext},应用层不再看) |
origin_name | 用户可见原名 = My Document.pdf(含扩展名) | 拆分 → FileEntry.name='My Document'(无扩展名)+ FileEntry.ext='pdf'(无前导点) |
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)等。这些和本次迁移无关。
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'。
// 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 : '')增加一个集中式工具(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)。
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(存储名)丢弃 |
ext 的前导点问题(关联 §2.5)旧 FileMetadata.ext = '.pdf'(含前导点);v2 FileEntry.ext = 'pdf'(不含)。
所有做字符串比较的消费者要更新:
file.ext === '.pdf' → entry.ext === 'pdf'file.ext.replace('.', '') 之类的 hacky 写法可以删除path.extname() 返回 .pdf,需要 .slice(1) 或 .replace(/^\./, ''))这个和 §2.5 type 字段迁移同步进行,可以合并到一个 PR 里。
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 或用户手动改过),做防御性处理。
旧: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 注意:
name 只是 DB 更新,物理文件路径({id}.{ext})不变{newName}.{ext},rename externalPath这个逻辑在 §2.6 Step I 的 FileManager rename 实现里体现。
| 维度 | 评估 |
|---|---|
| 触达 producer | ~10 文件 |
| 触达 consumer | ~15 文件 |
| 改造性质 | 多数是字段访问替换;少数需要语义调整(ext 前导点、rename 行为) |
| 最难点 | Remote upload 服务(OpenAI/Gemini/Mistral)对 display name 的格式预期;预处理 provider 的产物名派生 |
| 复杂度 | M(介于 count 和 path 之间) |
建议拆分:
| 风险 | 缓解 |
|---|---|
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 删索引(可多版本阶段化) |
created_at: string 字段结论:低复杂度。ISO 8601 string → FileEntry.createdAt: number(ms epoch)。所有消费者已经通过 dayjs 读取,dayjs() 同时接受 string 和 number,所以消费侧改动极小,主要是 Producer 改写 setter 和 Migrator 做一次类型转换。
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 epochStep 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 不强约束类型) |
| 维度 | 评估 |
|---|---|
| 触达 Producer | ~11 位置 |
| 触达 Consumer | ~4 位置(全是 dayjs 无需改) |
| 复杂度 | S(仅次于 tokens,几乎纯替换) |
风险:
dayjs 天然兼容;FileMigrator 的 new Date(iso).getTime() 有成熟 patternDate 对象而非 string(见 upgrade script :48)——FileMigrator 对 created_at 做 typeof 分支兜底,所有不合法值回退到 Date.now()执行时机:独立小 PR,与 §2.4 tokens 类似,可以打包到"字段清理"合并 PR,甚至和 ext 前导点迁移合并。
id: UUID v4 → UUID v7 迁移决策:
保留原 v4 ID;新 entry 自动用 v7。放宽
FileEntryIdSchema为z.uuid()接受两种。跨表引用零翻译。
v2 设计(当前代码):
src/main/data/db/schemas/file.ts:25):id: uuidPrimaryKeyOrdered() —— 新 entry 自动生成 UUID v7(_columnHelpers.ts:26)src/shared/data/types/file/fileEntry.ts:64):FileEntryIdSchema = z.uuidv7() —— 严格要求 v7fileEntry.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 校验。
Option A: 保留原 v4 + 放宽 Schema(推荐)
file_entry.idFileEntryIdSchema 从 z.uuidv7() 到 z.uuid()(接受 v4 和 v7 两种)uuidPrimaryKeyOrdered() 默认生成 v7Option B: 重生成 v7 + ID 映射
oldId → newId 映射取舍:
| 维度 | 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 的理由:
z.uuid() 仍然能拦住 garbage 字符串(真正的 schema validation 目的)pathResolver 类似决策(信任输入 + 最小化改动面)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"
? 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 必须:
canonicalizeAbsolutePath(path) 分组createdAt 最早的)作为 surviving rowfile_ref(以及其他引用 FileMetadata.id 的业务表)时将旧 id 重路由到 surviving idfile_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 的主要简化点。
| 风险 | 缓解 |
|---|---|
| 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) |
S。唯一实质改动是 schema 放宽 + test 更新。FileMigrator 的 id 字段处理是"直接拷贝"。
执行时机:Schema 放宽(Step A)可随时独立 PR;FileMigrator 实现(Step B)在 Batch 0 FileMigrator 整合时落地。
本节不重复 §2.2–§2.9 的字段映射,而是把 FileMigrator 的整体职责、在引擎中的位置、与业务 migrator 的协作契约、失败处理和观测性串成一份规约。字段级映射的具体规则散在前面各节(§2.7 拆分 / §2.8 created_at / §2.9 id 保留 / §2.3 file_ref 来源),本节只在 §2.10.6 用一张交叉引用表回链。
src/main/data/migration/v2/migrators/FileMigrator.ts(新增;对齐其他 migrator 的命名风格)'file'BaseMigrator.order):建议 order = 2.7——在 AgentsMigrator (2.5) 之后、KnowledgeMigrator (3) 之前。这保证:
FileEntry 的业务 migrator(Knowledge 3 / Chat 4 / 未来 Painting)都在 FileMigrator 之后跑MigrationContext.db —— SQLite 写入MigrationContext.sources.dexieExport.tableExists('files') / createStreamReader('files') —— 读 v1 Dexie 导出的 files.jsonfile_entry 行(来自 v1 Dexie files 表,经 external 去重后)file_ref 行——所有 ref 由对应业务 migrator 在迁移引用源时写入(见 §2.10.3)MigrationContext.sharedData 写入跨 migrator 协议产物(见 §2.10.3)前置假设: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 时代就存在。
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 去重——canonicalizeAbsolutePath 合并掉的 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 ...
}
关键约定:
idRemap——这是 surviving id 的唯一来源;internal 文件查表 miss 时退回原 id(即 v4),external 文件未经查表写入会引用已合并掉的 loser idfileRefService.create(或等价 db.insert(fileRef))在迁移期使用 onConflictDoNothing 语义——避免重跑时触发 UNIQUE(fileEntryId, sourceType, sourceId, role) 冲突recordWarning 跳过;不整批回滚。理由:v1 count 本就是启发式数字,少几行 ref 不破坏迁移完整性;后台 OrphanRefScanner 兜底——漏建的 ref 体现为 file_entry "零引用",由 cleanup UI 展示给用户fileEntryId 在 knownIds 缺失(FileMigrator 没迁成功)→ recordWarning 跳过整个 ref。比"插入 ref 引用不存在的 entry"安全:后者会破坏 FK 完整性| 失败场景 | 处理策略 |
|---|---|
dexieExport.tableExists('files') 返回 false | recordWarning 后跳过;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 协议保证一致性。
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 字段映射节 | 在 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 翻译) |
| 维度 | 评估 |
|---|---|
| 触达文件数 | 1 新文件(FileMigrator.ts)+ MigrationContext 类型扩展 + ~3 业务 migrator 改造(注 file_ref 写入) |
| 改造性质 | "读 Dexie → 字段转换 → 去重 → 单事务批量 INSERT" + 业务 migrator 加 file_ref 写入逻辑 |
| 最大复杂点 | External 去重 surviving 选择 + idRemap 跨 migrator 传递契约 |
| 复杂度 | M |
前置条件 / PR 拆分顺序:
FileEntryIdSchema 放宽至 z.uuid()(独立 PR,可先行)MigrationContext.sharedData 协议扩展(添加 fileMigrator.idRemap / fileMigrator.knownIds 的 key 与读写工具,建议封装成 getFileMigratorProducts(ctx) helper 避免各 migrator 自己 cast unknown)data.file.id 处理流程后追加 file_ref 写入;loadFileLookup 去留按 Q9 结论)allSourceTypes 增加 'chat_message'、createRefSchema 变体、OrphanRefScanner checker)总览:详见
filemetadata-consumer-audit.md §6。本节聚焦具体切换顺序和动作。
| 批次 | 域 | 复杂度 | 依赖 | 预期 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 |
顺序依据:
每个域的切换 PR 遵循相同步骤,逐个域展开时填入具体内容:
window.api.file.* 的哪几个方法(占位——每个 Batch 按 §3.2 模板展开)
本节覆盖字段退役(§2)和 Batch A-E 域切换(§3.1)之外的跨模块协调点。这些事项不属于任何单个 consumer 的迁移,而是切换期需要全局协调的时序、phasing 和下线计划。
v1 现状:
src/main/services/BackupManager.ts 通过 fs-extra.copy 复制 userData 目录到打包暂存目录Data/Files/*)+ Dexie 导出(含 files.json)+ LocalStorage / Redux state 导出v2 切换后的结构变化:
files 表 Cleanup Batch 删除,SQLite file_entry / file_ref 进场count-fraction 触发会变成日常发生)file_entry 行指向不存在物理文件,全部变 dangling改造点(按依赖顺序):
| # | 文件 | 改动 |
|---|---|---|
| B1 | src/main/services/BackupManager.ts | 备份产物结构扩展:除 Dexie 导出外,dump SQLite DB 文件(v2 已有的 file_entry / file_ref 表所在 DB)。建议路径 <backup>/sqlite/<db-name>.db;先 DbService.checkpoint() 把 WAL flush 到主文件再 copy |
| B2 | BackupManager 恢复路径 | 增加 SQLite DB 还原步骤:在恢复物理文件之前,先把 backup 中的 sqlite 文件 atomically rename 到目标位置(DbService 启动前完成) |
| B3 | BackupManager 版本兼容 | 检测 backup 产物是 v1(无 sqlite 目录)还是 v2(含 sqlite 目录)。v1 backup 恢复到 v2 环境:保留 Dexie 导出,让 FileMigrator 走正常迁移路径 |
| B4 | FileManager.runStartupSweeps / OrphanRefScanner | 在「刚完成 backup restore」标志位下首次启动时跳过 orphan sweep 一次——给 DB ↔ FS 一个对齐窗口;标志位由 BackupManager 在 restore 完成时 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 数据。
问题:
Background phase 启动,扫描 file_ref 找 sourceId 不存在的行删掉runStartupSweeps(启动期 file-sweep + orphan-entry-report)同样在 onInit 后 fire-and-forgetorphan-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 提前实现就会出问题)。
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 类型本身 |
关键决策:
复杂度:M(renderer 多处入口要审计 + DexieFilesFrozenError 接线)。
执行时机:Phase 2 启动作为 Batch A 之前的 prerequisite PR。
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.* |
改造原则:
@deprecated JSDoc + 首次调用 console.warn 一次,避免日志洪水)delete preload 暴露 + 对应 IPC handler 注册复杂度:M(49 个 API 逐个 audit;多数是机械下线,少数需要先确认所有调用点都已切换)。
执行时机:各 Batch 完成时 deprecate;Cleanup Batch 一次性删除。
remotefile/* services 过渡期生命周期v1 现状:
src/main/services/remotefile/{Gemini,OpenAI,Mistral}Service.ts 实现 BaseFileService 接口(uploadFile / retrieveFile / listFiles / deleteFile)window.api.fileService.upload(provider, file) 走这些 servicesfileProcessor.ts 在上传前查 cached fileId,命中则跳过v2 终态:
FileUploadService + file_upload 表(RFC §9.8 Phase X,依赖 Vercel AI SDK 稳定)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 |
关键决策:
remotefile/* services 继续担当 upload 通道,仅需在 displayName / purpose 字段层对接 v2 schema(已在 §2.2 / §2.7 规约)fileProcessor.ts 旧 cache 逻辑临时保留在 renderer 内存里——避免 file_upload 表设计在 Vercel AI SDK 稳定前被锁死createDirectoryWatcher 后,自定义 IPC channel(建议 <module>-event,如 notes-event)把事件转发给 renderer;不复用 file-manager-event 通道——后者由 FileManager 占有,业务事件不应混入复杂度:S(字段层适配已在 §2.2 / §2.7 规约,本节只追加生命周期约定)。
执行时机:Batch B 完成;Phase X 落地时进入下线倒计时。
| 子节 | 主题 | 严重性 | 与 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(删) |
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 拒收
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 侧不应自拼旧代码产出的
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,签名改掉就好
toFileMetadata(仅桶 P 用)FileEntry,shim 调用点递减toFileMetadata 与 FileMetadata 类型本身;FileInfo / FileEntry / FileHandle 成为唯一表达{userData}/files/{id}.{ext},FileMigrator 只建表、不动盘| 里程碑 | 内容 | 依赖 |
|---|---|---|
| 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 |
切换 PR 应在描述中包含:
| # | 问题 | 状态 | 决策 |
|---|---|---|---|
| 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 |
| 旧术语 | 新术语 | 备注 |
|---|---|---|
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 |
| 日期 | 版本 | 变更 |
|---|---|---|
| 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 同步勾掉对应条目 |