diff --git a/migrations/sqlite-drizzle/0002_perpetual_molten_man.sql b/migrations/sqlite-drizzle/0002_perpetual_molten_man.sql new file mode 100644 index 00000000000..3ac2b1ec53c --- /dev/null +++ b/migrations/sqlite-drizzle/0002_perpetual_molten_man.sql @@ -0,0 +1,38 @@ +CREATE TABLE `file_ref` ( + `id` text PRIMARY KEY NOT NULL, + `node_id` text NOT NULL, + `source_type` text NOT NULL, + `source_id` text NOT NULL, + `role` text NOT NULL, + `created_at` integer, + `updated_at` integer, + FOREIGN KEY (`node_id`) REFERENCES `node`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `file_ref_node_id_idx` ON `file_ref` (`node_id`);--> statement-breakpoint +CREATE INDEX `file_ref_source_idx` ON `file_ref` (`source_type`,`source_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `file_ref_unique_idx` ON `file_ref` (`node_id`,`source_type`,`source_id`,`role`);--> statement-breakpoint +CREATE TABLE `node` ( + `id` text PRIMARY KEY NOT NULL, + `type` text NOT NULL, + `name` text NOT NULL, + `ext` text, + `parent_id` text, + `mount_id` text NOT NULL, + `size` integer, + `provider_config` text, + `is_readonly` integer DEFAULT false, + `remote_id` text, + `cached_at` integer, + `previous_parent_id` text, + `created_at` integer, + `updated_at` integer, + FOREIGN KEY (`parent_id`) REFERENCES `node`(`id`) ON UPDATE no action ON DELETE cascade, + CONSTRAINT "node_type_check" CHECK("node"."type" IN ('file', 'dir', 'mount')) +); +--> statement-breakpoint +CREATE INDEX `node_parent_id_idx` ON `node` (`parent_id`);--> statement-breakpoint +CREATE INDEX `node_mount_id_idx` ON `node` (`mount_id`);--> statement-breakpoint +CREATE INDEX `node_mount_type_idx` ON `node` (`mount_id`,`type`);--> statement-breakpoint +CREATE INDEX `node_name_idx` ON `node` (`name`);--> statement-breakpoint +CREATE INDEX `node_updated_at_idx` ON `node` (`updated_at`); \ No newline at end of file diff --git a/migrations/sqlite-drizzle/meta/0002_snapshot.json b/migrations/sqlite-drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000000..39d0ed17f2e --- /dev/null +++ b/migrations/sqlite-drizzle/meta/0002_snapshot.json @@ -0,0 +1,847 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6bb0f9c3-8fc7-423f-ab6c-5dd81e8c30cd", + "prevId": "a433b120-0ab8-4f3f-9d1d-766b48c216c8", + "tables": { + "app_state": { + "name": "app_state", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "group_entity_sort_idx": { + "name": "group_entity_sort_idx", + "columns": ["entity_type", "sort_order"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message": { + "name": "message", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "searchable_text": { + "name": "searchable_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "siblings_group_id": { + "name": "siblings_group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_meta": { + "name": "assistant_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_meta": { + "name": "model_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "message_parent_id_idx": { + "name": "message_parent_id_idx", + "columns": ["parent_id"], + "isUnique": false + }, + "message_topic_created_idx": { + "name": "message_topic_created_idx", + "columns": ["topic_id", "created_at"], + "isUnique": false + }, + "message_trace_id_idx": { + "name": "message_trace_id_idx", + "columns": ["trace_id"], + "isUnique": false + } + }, + "foreignKeys": { + "message_topic_id_topic_id_fk": { + "name": "message_topic_id_topic_id_fk", + "tableFrom": "message", + "tableTo": "topic", + "columnsFrom": ["topic_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_parent_id_message_id_fk": { + "name": "message_parent_id_message_id_fk", + "tableFrom": "message", + "tableTo": "message", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "message_role_check": { + "name": "message_role_check", + "value": "\"message\".\"role\" IN ('user', 'assistant', 'system')" + }, + "message_status_check": { + "name": "message_status_check", + "value": "\"message\".\"status\" IN ('pending', 'success', 'error', 'paused')" + } + } + }, + "file_ref": { + "name": "file_ref", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "file_ref_node_id_idx": { + "name": "file_ref_node_id_idx", + "columns": ["node_id"], + "isUnique": false + }, + "file_ref_source_idx": { + "name": "file_ref_source_idx", + "columns": ["source_type", "source_id"], + "isUnique": false + }, + "file_ref_unique_idx": { + "name": "file_ref_unique_idx", + "columns": ["node_id", "source_type", "source_id", "role"], + "isUnique": true + } + }, + "foreignKeys": { + "file_ref_node_id_node_id_fk": { + "name": "file_ref_node_id_node_id_fk", + "tableFrom": "file_ref", + "tableTo": "node", + "columnsFrom": ["node_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "node": { + "name": "node", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mount_id": { + "name": "mount_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_config": { + "name": "provider_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_readonly": { + "name": "is_readonly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "remote_id": { + "name": "remote_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cached_at": { + "name": "cached_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "previous_parent_id": { + "name": "previous_parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "node_parent_id_idx": { + "name": "node_parent_id_idx", + "columns": ["parent_id"], + "isUnique": false + }, + "node_mount_id_idx": { + "name": "node_mount_id_idx", + "columns": ["mount_id"], + "isUnique": false + }, + "node_mount_type_idx": { + "name": "node_mount_type_idx", + "columns": ["mount_id", "type"], + "isUnique": false + }, + "node_name_idx": { + "name": "node_name_idx", + "columns": ["name"], + "isUnique": false + }, + "node_updated_at_idx": { + "name": "node_updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "node_parent_id_node_id_fk": { + "name": "node_parent_id_node_id_fk", + "tableFrom": "node", + "tableTo": "node", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "node_type_check": { + "name": "node_type_check", + "value": "\"node\".\"type\" IN ('file', 'dir', 'mount')" + } + } + }, + "preference": { + "name": "preference", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "preference_scope_key_pk": { + "columns": ["scope", "key"], + "name": "preference_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entity_tag": { + "name": "entity_tag", + "columns": { + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "entity_tag_tag_id_idx": { + "name": "entity_tag_tag_id_idx", + "columns": ["tag_id"], + "isUnique": false + } + }, + "foreignKeys": { + "entity_tag_tag_id_tag_id_fk": { + "name": "entity_tag_tag_id_tag_id_fk", + "tableFrom": "entity_tag", + "tableTo": "tag", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entity_tag_entity_type_entity_id_tag_id_pk": { + "columns": ["entity_type", "entity_id", "tag_id"], + "name": "entity_tag_entity_type_entity_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tag_name_unique": { + "name": "tag_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "topic": { + "name": "topic", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_name_manually_edited": { + "name": "is_name_manually_edited", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_meta": { + "name": "assistant_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_node_id": { + "name": "active_node_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "pinned_order": { + "name": "pinned_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "topic_group_updated_idx": { + "name": "topic_group_updated_idx", + "columns": ["group_id", "updated_at"], + "isUnique": false + }, + "topic_group_sort_idx": { + "name": "topic_group_sort_idx", + "columns": ["group_id", "sort_order"], + "isUnique": false + }, + "topic_updated_at_idx": { + "name": "topic_updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + }, + "topic_is_pinned_idx": { + "name": "topic_is_pinned_idx", + "columns": ["is_pinned", "pinned_order"], + "isUnique": false + }, + "topic_assistant_id_idx": { + "name": "topic_assistant_id_idx", + "columns": ["assistant_id"], + "isUnique": false + } + }, + "foreignKeys": { + "topic_group_id_group_id_fk": { + "name": "topic_group_id_group_id_fk", + "tableFrom": "topic", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/migrations/sqlite-drizzle/meta/_journal.json b/migrations/sqlite-drizzle/meta/_journal.json index c2bac3b3252..1f1a1652f7c 100644 --- a/migrations/sqlite-drizzle/meta/_journal.json +++ b/migrations/sqlite-drizzle/meta/_journal.json @@ -14,6 +14,13 @@ "tag": "0001_futuristic_human_fly", "version": "6", "when": 1767455592181 + }, + { + "breakpoints": true, + "idx": 2, + "tag": "0002_perpetual_molten_man", + "version": "6", + "when": 1773477695180 } ], "version": "7" diff --git a/packages/shared/data/api/schemas/files.ts b/packages/shared/data/api/schemas/files.ts new file mode 100644 index 00000000000..303eceac28b --- /dev/null +++ b/packages/shared/data/api/schemas/files.ts @@ -0,0 +1,211 @@ +/** + * File API Schema definitions + * + * Contains all file-related endpoints for node CRUD, tree operations, + * file references, and mount management. + */ + +import type { CreateFileRefDto, CreateNodeDto, FileNode, FileRef, UpdateNodeDto } from '@shared/data/types/fileNode' + +// ============================================================================ +// API Schema Definitions +// ============================================================================ + +/** + * File API Schema definitions + * + * Organized by domain responsibility: + * - /files/nodes - Node CRUD and listing + * - /files/nodes/:id/* - Tree operations (children, move, trash, restore) + * - /files/nodes/:id/refs - File references per node + * - /files/refs/by-source - File references by business source + * - /files/nodes/batch/* - Batch operations + * - /files/mounts - Mount point listing + */ +export interface FileSchemas { + // ─── Node CRUD ─── + + /** + * Nodes collection endpoint + * @example GET /files/nodes?mountId=mount_files&type=file + * @example POST /files/nodes { "type": "file", "name": "doc", "parentId": "..." } + */ + '/files/nodes': { + /** List nodes with filters */ + GET: { + query: { + mountId?: string + parentId?: string + type?: 'file' | 'dir' + inTrash?: boolean + } + response: FileNode[] + } + /** Create a node (upload file / create directory) */ + POST: { + body: CreateNodeDto + response: FileNode + } + } + + /** + * Individual node endpoint + * @example GET /files/nodes/abc123 + * @example PATCH /files/nodes/abc123 { "name": "renamed" } + * @example DELETE /files/nodes/abc123 + */ + '/files/nodes/:id': { + /** Get a node by ID */ + GET: { + params: { id: string } + response: FileNode + } + /** Update node metadata (rename, etc.) */ + PATCH: { + params: { id: string } + body: UpdateNodeDto + response: FileNode + } + /** Permanently delete a node */ + DELETE: { + params: { id: string } + response: void + } + } + + // ─── Tree Operations ─── + + /** + * Children endpoint for lazy-loading file tree + * @example GET /files/nodes/abc123/children?sortBy=name&sortOrder=asc + */ + '/files/nodes/:id/children': { + /** Get child nodes with sorting and pagination */ + GET: { + params: { id: string } + query: { + recursive?: boolean + /** Max tree depth when recursive=true. Clamped to server maximum (default: 20) */ + maxDepth?: number + sortBy?: 'name' | 'updatedAt' | 'size' | 'type' + sortOrder?: 'asc' | 'desc' + limit?: number + offset?: number + } + response: FileNode[] + } + } + + /** + * Move node to a new parent + * @example PUT /files/nodes/abc123/move { "targetParentId": "dir456" } + */ + '/files/nodes/:id/move': { + PUT: { + params: { id: string } + body: { targetParentId: string } + response: FileNode + } + } + + /** + * Trash a node (soft delete) + * @example PUT /files/nodes/abc123/trash + */ + '/files/nodes/:id/trash': { + PUT: { + params: { id: string } + response: void + } + } + + /** + * Restore a node from Trash + * @example PUT /files/nodes/abc123/restore + */ + '/files/nodes/:id/restore': { + PUT: { + params: { id: string } + response: FileNode + } + } + + // ─── File References ─── + + /** + * File references for a specific node + * @example GET /files/nodes/abc123/refs + * @example POST /files/nodes/abc123/refs { "sourceType": "chat_message", "sourceId": "msg1", "role": "attachment" } + */ + '/files/nodes/:id/refs': { + /** Get all references for a file node */ + GET: { + params: { id: string } + response: FileRef[] + } + /** Create a reference to a file node */ + POST: { + params: { id: string } + body: CreateFileRefDto + response: FileRef + } + } + + /** + * File references by business source + * @example GET /files/refs/by-source?sourceType=chat_message&sourceId=msg1 + * @example DELETE /files/refs/by-source?sourceType=chat_message&sourceId=msg1 + */ + '/files/refs/by-source': { + /** Get all file references for a business object */ + GET: { + query: { sourceType: string; sourceId: string } + response: FileRef[] + } + /** Clean up all references for a business object */ + DELETE: { + query: { sourceType: string; sourceId: string } + response: void + } + } + + // ─── Batch Operations ─── + + /** Batch trash nodes */ + '/files/nodes/batch/trash': { + PUT: { + body: { ids: string[] } + response: void + } + } + + /** Batch move nodes to target directory */ + '/files/nodes/batch/move': { + PUT: { + body: { ids: string[]; targetParentId: string } + response: void + } + } + + /** Batch permanently delete nodes (uses POST to avoid DELETE-with-body compatibility issues) */ + '/files/nodes/batch/delete': { + POST: { + body: { ids: string[] } + response: void + } + } + + // ─── Mounts ─── + + /** + * Mount points listing + * @example GET /files/mounts?includeSystem=true + */ + '/files/mounts': { + /** Get mount point list (excludes system mounts like Trash by default) */ + GET: { + query: { includeSystem?: boolean } + response: FileNode[] + } + } +} diff --git a/packages/shared/data/api/schemas/index.ts b/packages/shared/data/api/schemas/index.ts index 703b92ff247..e505dfe6541 100644 --- a/packages/shared/data/api/schemas/index.ts +++ b/packages/shared/data/api/schemas/index.ts @@ -20,6 +20,7 @@ */ import type { AssertValidSchemas } from '../apiTypes' +import type { FileSchemas } from './files' import type { MessageSchemas } from './messages' import type { TestSchemas } from './test' import type { TopicSchemas } from './topics' @@ -36,4 +37,4 @@ import type { TopicSchemas } from './topics' * 1. Create the schema file (e.g., topic.ts) * 2. Import and add to intersection below */ -export type ApiSchemas = AssertValidSchemas +export type ApiSchemas = AssertValidSchemas diff --git a/packages/shared/data/types/fileNode.ts b/packages/shared/data/types/fileNode.ts new file mode 100644 index 00000000000..1f55c1839e6 --- /dev/null +++ b/packages/shared/data/types/fileNode.ts @@ -0,0 +1,234 @@ +/** + * File node and file reference entity types + * + * Zod schemas for runtime validation of file nodes and references. + * FileNode represents a unified file/directory/mount node in the file tree. + * FileRef tracks which business entities reference which file nodes. + * Timestamps are numbers (ms epoch) matching DB integer storage. + * + * ## Node type invariants + * + * | Field | type=mount | type=dir | type=file | + * |------------------|-------------------------|--------------|------------------------| + * | parentId | null | non-null | non-null | + * | mountId | equals own `id` | inherited | inherited | + * | providerConfig | non-null | null | null | + * | ext | null | null | string or null (null for extensionless files) | + * | size | null | null | number or null | + * | remoteId | null | null | set under remote mount | + * | cachedAt | null | null | ms epoch or null under remote mount | + * + * ## Node lifecycle state machine + * + * ``` + * ┌──────────┐ + * ┌────────│ Active │←───────┐ + * │ └────┬─────┘ │ + * │ │ trash() │ restore() + * │ ▼ │ + * │ ┌──────────┐ │ + * │ │ Trashed │────────┘ + * │ └────┬─────┘ + * │ │ permanentDelete() + * │ ▼ + * │ ┌──────────┐ + * └───────→│ Deleted │ + * permanentDelete└──────────┘ + * ``` + * + * ### State-dependent field constraints + * + * | Field | Active | Trashed (direct child of Trash) | + * |-----------------|-------------------------|----------------------------------| + * | parentId | points to parent node | `system_trash` | + * | previousParentId| null | original parentId before trash | + * | mountId | unchanged | unchanged (keeps original mount) | + * + * Note: Nested children of a trashed node remain in "Active" shape — only the + * top-level trashed node gets `parentId=system_trash` and `previousParentId` set. + */ + +import * as z from 'zod' + +import { MountProviderConfigSchema } from './fileProvider' + +// ─── System Node IDs ─── + +/** Well-known system mount node IDs, created at app initialization */ +export const SYSTEM_MOUNT_FILES = 'mount_files' as const +export const SYSTEM_MOUNT_NOTES = 'mount_notes' as const +export const SYSTEM_TRASH = 'system_trash' as const +export const SYSTEM_NODE_IDS = [SYSTEM_MOUNT_FILES, SYSTEM_MOUNT_NOTES, SYSTEM_TRASH] as const +export const SystemNodeIdSchema = z.enum(SYSTEM_NODE_IDS) +export type SystemNodeId = z.infer + +/** Accepts UUID v7 or a known system node ID */ +export const NodeIdSchema = z.union([z.uuidv7(), SystemNodeIdSchema]) + +// ─── Node Type ─── + +export const FileNodeTypeSchema = z.enum(['file', 'dir', 'mount']) +export type FileNodeType = z.infer + +// ─── Entity Types ─── + +/** Complete file node entity as stored in database */ +export const FileNodeSchema = z + .object({ + /** Node ID (UUID v7, or a system node ID for mount nodes) */ + id: NodeIdSchema, + /** Node type */ + type: FileNodeTypeSchema, + /** User-visible name (without extension) */ + name: z.string().min(1), + /** File extension without leading dot (e.g. 'pdf', 'md'). Null for dirs/mounts or extensionless files (e.g. Dockerfile) */ + ext: z.string().min(1).nullable(), + /** Parent node ID. Null for mount nodes (top-level) */ + parentId: NodeIdSchema.nullable(), + /** + * Mount ID this node belongs to. For mount nodes, equals own id. + * Known system mounts: `mount_files`, `mount_notes`, `system_trash`. + * Can also be a dynamic ID for user-added remote mounts. + */ + mountId: NodeIdSchema, + /** File size in bytes. Null for dirs/mounts */ + size: z.int().nonnegative().nullable(), + /** Provider config JSON (only for mount nodes) */ + providerConfig: MountProviderConfigSchema.nullable(), + /** Whether the node is read-only */ + isReadonly: z.boolean(), + /** Remote file ID (e.g. OpenAI file-abc123) */ + remoteId: z.string().nullable(), + /** When the local cache was last downloaded (ms epoch). Null if not cached. Compare with remote updatedAt to detect staleness */ + cachedAt: z.int().nullable(), + /** Original parent ID before moving to Trash (only for Trash direct children) */ + previousParentId: NodeIdSchema.nullable(), + /** Creation timestamp (ms epoch) */ + createdAt: z.int(), + /** Last update timestamp (ms epoch) */ + updatedAt: z.int() + }) + .superRefine((node, ctx) => { + // ─── Type invariants ─── + switch (node.type) { + case 'mount': + if (node.parentId !== null) { + ctx.addIssue({ code: 'custom', path: ['parentId'], message: 'Mount nodes must have parentId = null' }) + } + if (node.mountId !== node.id) { + ctx.addIssue({ code: 'custom', path: ['mountId'], message: 'Mount nodes must have mountId = own id' }) + } + if (node.providerConfig === null) { + ctx.addIssue({ code: 'custom', path: ['providerConfig'], message: 'Mount nodes must have providerConfig' }) + } + if (node.ext !== null) { + ctx.addIssue({ code: 'custom', path: ['ext'], message: 'Mount nodes must not have ext' }) + } + if (node.size !== null) { + ctx.addIssue({ code: 'custom', path: ['size'], message: 'Mount nodes must not have size' }) + } + if (node.remoteId !== null) { + ctx.addIssue({ code: 'custom', path: ['remoteId'], message: 'Mount nodes must not have remoteId' }) + } + if (node.cachedAt !== null) { + ctx.addIssue({ code: 'custom', path: ['cachedAt'], message: 'Mount nodes must have cachedAt = null' }) + } + break + case 'dir': + if (node.parentId === null) { + ctx.addIssue({ code: 'custom', path: ['parentId'], message: 'Dir nodes must have a parentId' }) + } + if (node.providerConfig !== null) { + ctx.addIssue({ code: 'custom', path: ['providerConfig'], message: 'Dir nodes must not have providerConfig' }) + } + if (node.ext !== null) { + ctx.addIssue({ code: 'custom', path: ['ext'], message: 'Dir nodes must not have ext' }) + } + if (node.size !== null) { + ctx.addIssue({ code: 'custom', path: ['size'], message: 'Dir nodes must not have size' }) + } + if (node.remoteId !== null) { + ctx.addIssue({ code: 'custom', path: ['remoteId'], message: 'Dir nodes must not have remoteId' }) + } + if (node.cachedAt !== null) { + ctx.addIssue({ code: 'custom', path: ['cachedAt'], message: 'Dir nodes must have cachedAt = null' }) + } + break + case 'file': + if (node.parentId === null) { + ctx.addIssue({ code: 'custom', path: ['parentId'], message: 'File nodes must have a parentId' }) + } + if (node.providerConfig !== null) { + ctx.addIssue({ code: 'custom', path: ['providerConfig'], message: 'File nodes must not have providerConfig' }) + } + break + } + + // ─── Trash state invariants ─── + if (node.previousParentId !== null && node.parentId !== SYSTEM_TRASH) { + ctx.addIssue({ + code: 'custom', + path: ['previousParentId'], + message: 'previousParentId must only be set when parentId = system_trash' + }) + } + }) +export type FileNode = z.infer + +/** File reference entity — tracks business entity to file node relationships */ +export const FileRefSchema = z.object({ + /** Reference ID (UUID v4) */ + id: z.uuidv4(), + /** Referenced file node ID */ + nodeId: z.uuidv7(), + /** Business source type (e.g. 'chat_message', 'knowledge_item', 'painting') */ + sourceType: z.string().min(1), + /** Business object ID (polymorphic, no FK constraint) */ + sourceId: z.string().min(1), + /** Reference role (e.g. 'attachment', 'source', 'asset') */ + role: z.string().min(1), + /** Creation timestamp (ms epoch) */ + createdAt: z.int(), + /** Last update timestamp (ms epoch) */ + updatedAt: z.int() +}) +export type FileRef = z.infer + +// ─── DTOs ─── + +/** DTO for creating a new file or directory node */ +export const CreateNodeDtoSchema = z.object({ + /** Node type (file or dir, not mount) */ + type: z.enum(['file', 'dir']), + /** User-visible name */ + name: z.string().min(1), + /** File extension without leading dot */ + ext: z.string().min(1).optional(), + /** Parent node ID */ + parentId: NodeIdSchema, + /** Mount ID */ + mountId: NodeIdSchema, + /** File size in bytes */ + size: z.int().nonnegative().optional() +}) +export type CreateNodeDto = z.infer + +/** DTO for updating a node's metadata */ +export const UpdateNodeDtoSchema = z.object({ + /** Updated name */ + name: z.string().min(1).optional(), + /** Updated extension */ + ext: z.string().min(1).optional() +}) +export type UpdateNodeDto = z.infer + +/** DTO for creating a file reference */ +export const CreateFileRefDtoSchema = z.object({ + /** Business source type */ + sourceType: z.string().min(1), + /** Business object ID */ + sourceId: z.string().min(1), + /** Reference role */ + role: z.string().min(1) +}) +export type CreateFileRefDto = z.infer diff --git a/packages/shared/data/types/fileProvider.ts b/packages/shared/data/types/fileProvider.ts new file mode 100644 index 00000000000..e0ec38ea467 --- /dev/null +++ b/packages/shared/data/types/fileProvider.ts @@ -0,0 +1,69 @@ +/** + * Mount provider configuration types + * + * Zod schemas for runtime validation of provider config JSON stored in DB. + * Each mount node has a providerConfig field describing its storage mode. + */ + +import * as z from 'zod' + +// ─── Provider Type Enum ─── + +export const MountProviderTypeSchema = z.enum(['local_managed', 'local_external', 'remote', 'system']) +export type MountProviderType = z.infer + +// ─── Remote API Type Enum ─── + +export const RemoteApiTypeSchema = z.enum([ + 'openai_files' + // Future: 's3', 'webdav', 'google_drive', ... +]) +export type RemoteApiType = z.infer + +// ─── Provider Config Schemas ─── + +/** Managed files: app-internal storage, UUID-based naming */ +export const LocalManagedConfigSchema = z.object({ + provider_type: z.literal('local_managed'), + base_path: z.string().min(1) +}) + +/** External files: filesystem as source of truth, human-readable naming */ +export const LocalExternalConfigSchema = z.object({ + provider_type: z.literal('local_external'), + base_path: z.string().min(1), + watch: z.boolean().default(true), + watch_extensions: z.array(z.string()).optional() +}) + +/** Remote files: accessed via API */ +export const RemoteConfigSchema = z.object({ + provider_type: z.literal('remote'), + api_type: RemoteApiTypeSchema, + provider_id: z.string().min(1), + cache_path: z.string().optional(), + auto_sync: z.boolean().default(false), + options: z.record(z.string(), z.unknown()).default({}) +}) + +/** System mount: no physical storage, used for structural nodes like Trash */ +export const SystemConfigSchema = z.object({ + provider_type: z.literal('system') +}) + +// ─── Discriminated Union ─── + +export const MountProviderConfigSchema = z.discriminatedUnion('provider_type', [ + LocalManagedConfigSchema, + LocalExternalConfigSchema, + RemoteConfigSchema, + SystemConfigSchema +]) +export type MountProviderConfig = z.infer + +// ─── Individual config types ─── + +export type LocalManagedConfig = z.infer +export type LocalExternalConfig = z.infer +export type RemoteConfig = z.infer +export type SystemConfig = z.infer diff --git a/src/main/data/api/handlers/files.ts b/src/main/data/api/handlers/files.ts new file mode 100644 index 00000000000..03f56537992 --- /dev/null +++ b/src/main/data/api/handlers/files.ts @@ -0,0 +1,63 @@ +/** + * File API Handlers (Placeholder) + * + * Stub handlers for Phase 1 — will be implemented in Phase 2. + * All endpoints throw NOT_IMPLEMENTED to satisfy ApiSchemas type requirements. + */ + +import type { ApiHandler, ApiMethods } from '@shared/data/api/apiTypes' +import type { FileSchemas } from '@shared/data/api/schemas/files' + +type FileHandler> = ApiHandler + +const notImplemented = (): never => { + throw new Error('Not implemented: file handlers will be added in Phase 2') +} + +export const fileHandlers: { + [Path in keyof FileSchemas]: { + [Method in keyof FileSchemas[Path]]: FileHandler> + } +} = { + '/files/nodes': { + GET: notImplemented, + POST: notImplemented + }, + '/files/nodes/:id': { + GET: notImplemented, + PATCH: notImplemented, + DELETE: notImplemented + }, + '/files/nodes/:id/children': { + GET: notImplemented + }, + '/files/nodes/:id/move': { + PUT: notImplemented + }, + '/files/nodes/:id/trash': { + PUT: notImplemented + }, + '/files/nodes/:id/restore': { + PUT: notImplemented + }, + '/files/nodes/:id/refs': { + GET: notImplemented, + POST: notImplemented + }, + '/files/refs/by-source': { + GET: notImplemented, + DELETE: notImplemented + }, + '/files/nodes/batch/trash': { + PUT: notImplemented + }, + '/files/nodes/batch/move': { + PUT: notImplemented + }, + '/files/nodes/batch/delete': { + POST: notImplemented + }, + '/files/mounts': { + GET: notImplemented + } +} diff --git a/src/main/data/api/handlers/index.ts b/src/main/data/api/handlers/index.ts index 87072fdfc00..9f084e591d6 100644 --- a/src/main/data/api/handlers/index.ts +++ b/src/main/data/api/handlers/index.ts @@ -12,6 +12,7 @@ import type { ApiImplementation } from '@shared/data/api/apiTypes' +import { fileHandlers } from './files' import { messageHandlers } from './messages' import { testHandlers } from './test' import { topicHandlers } from './topics' @@ -26,5 +27,6 @@ import { topicHandlers } from './topics' export const apiHandlers: ApiImplementation = { ...testHandlers, ...topicHandlers, - ...messageHandlers + ...messageHandlers, + ...fileHandlers } diff --git a/src/main/data/db/schemas/node.ts b/src/main/data/db/schemas/node.ts new file mode 100644 index 00000000000..9e41e5534ab --- /dev/null +++ b/src/main/data/db/schemas/node.ts @@ -0,0 +1,110 @@ +import type { MountProviderConfig } from '@shared/data/types/fileProvider' +import { sql } from 'drizzle-orm' +import { check, foreignKey, index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' + +import { createUpdateTimestamps, uuidPrimaryKey, uuidPrimaryKeyOrdered } from './_columnHelpers' + +/** + * Node table - unified file/directory/mount node entity + * + * Uses adjacency list pattern (parentId) for tree navigation. + * Mount nodes (type='mount') serve as root nodes with provider configuration. + * Trash is a system mount node (provider_type='system') for OS-style soft deletion. + */ +export const nodeTable = sqliteTable( + 'node', + { + id: uuidPrimaryKeyOrdered(), + + // ─── Core fields ─── + // Node type: file | dir | mount + type: text().notNull(), + // User-visible name (without extension) + name: text().notNull(), + // Extension without leading dot (e.g. 'pdf', 'md'). Null for dirs/mounts + ext: text(), + + // ─── Tree structure ─── + // Parent node ID. Null for mount nodes (top-level) + parentId: text(), + // Mount ID this node belongs to (redundant for query performance). Mount nodes: mountId = id + // Nodes in Trash keep their original mountId (pointing to the mount they belonged to before deletion) + mountId: text().notNull(), + + // ─── File attributes ─── + // File size in bytes. Null for dirs/mounts + size: integer(), + + // ─── Mount-only fields (type='mount') ─── + // Provider configuration JSON, validated by MountProviderConfigSchema + providerConfig: text({ mode: 'json' }).$type(), + // Whether the mount is read-only (remote sources may be read-only) + isReadonly: integer({ mode: 'boolean' }).default(false), + + // ─── Remote file fields (files under remote mounts) ─── + // Remote file ID (e.g. OpenAI file-abc123) + remoteId: text(), + // When the local cache was last downloaded (ms epoch). Null if not cached. + // Compare with remote updatedAt to detect staleness. + cachedAt: integer(), + + // ─── Trash fields ─── + // Original parent ID before moving to Trash (only set on Trash direct children) + previousParentId: text(), + + // ─── Timestamps ─── + ...createUpdateTimestamps + }, + (t) => [ + // Self-referencing FK: cascade delete children when parent is deleted (Trash cleanup) + foreignKey({ columns: [t.parentId], foreignColumns: [t.id] }).onDelete('cascade'), + // Indexes + index('node_parent_id_idx').on(t.parentId), + index('node_mount_id_idx').on(t.mountId), + index('node_mount_type_idx').on(t.mountId, t.type), + index('node_name_idx').on(t.name), + index('node_updated_at_idx').on(t.updatedAt), + // Type constraint + check('node_type_check', sql`${t.type} IN ('file', 'dir', 'mount')`) + ] +) + +/** + * File reference table - tracks which business entities reference which files + * + * Polymorphic association: sourceType + sourceId identify the referencing entity. + * No FK constraint on sourceId (polymorphic). Application-layer cleanup required + * when source entities are deleted. + * + * nodeId has CASCADE delete: removing a file node auto-removes its references. + */ +export const fileRefTable = sqliteTable( + 'file_ref', + { + id: uuidPrimaryKey(), + + // Referenced file node ID + nodeId: text() + .notNull() + .references(() => nodeTable.id, { onDelete: 'cascade' }), + + // Business source type (e.g. 'chat_message', 'knowledge_item', 'painting', 'note') + // Enum validated at application layer (Zod), no CHECK constraint + sourceType: text().notNull(), + // Business object ID (polymorphic, no FK constraint) + sourceId: text().notNull(), + // Reference role (e.g. 'attachment', 'source', 'asset') + role: text().notNull(), + + // ─── Timestamps ─── + ...createUpdateTimestamps + }, + (t) => [ + // Look up references by file node + index('file_ref_node_id_idx').on(t.nodeId), + // Look up referenced files by business object + index('file_ref_source_idx').on(t.sourceType, t.sourceId), + // Prevent duplicate references (same file cannot be referenced by same business object with same role twice) + uniqueIndex('file_ref_unique_idx').on(t.nodeId, t.sourceType, t.sourceId, t.role) + ] +) diff --git a/src/main/data/db/seeding/index.ts b/src/main/data/db/seeding/index.ts index cfa16f5709e..e49ff7340f9 100644 --- a/src/main/data/db/seeding/index.ts +++ b/src/main/data/db/seeding/index.ts @@ -1,7 +1,9 @@ +import NodeSeeding from './nodeSeeding' import PreferenceSeeding from './preferenceSeeding' const seedingList = { - preference: PreferenceSeeding + preference: PreferenceSeeding, + node: NodeSeeding } export default seedingList diff --git a/src/main/data/db/seeding/nodeSeeding.ts b/src/main/data/db/seeding/nodeSeeding.ts new file mode 100644 index 00000000000..b9126575dd8 --- /dev/null +++ b/src/main/data/db/seeding/nodeSeeding.ts @@ -0,0 +1,71 @@ +import { nodeTable } from '@data/db/schemas/node' +import { getFilesDir, getNotesDir } from '@main/utils/file' +import type { MountProviderConfig } from '@shared/data/types/fileProvider' +import { inArray } from 'drizzle-orm' + +import type { DbType, ISeed } from '../types' + +interface SystemNode { + id: string + type: 'mount' + name: string + mountId: string + parentId: null + providerConfig: MountProviderConfig +} + +function getSystemNodes(): SystemNode[] { + return [ + { + id: 'mount_files', + type: 'mount', + name: 'Files', + mountId: 'mount_files', + parentId: null, + providerConfig: { + provider_type: 'local_managed', + base_path: getFilesDir() + } + }, + { + id: 'mount_notes', + type: 'mount', + name: 'Notes', + mountId: 'mount_notes', + parentId: null, + providerConfig: { + provider_type: 'local_external', + base_path: getNotesDir(), + watch: true + } + }, + { + id: 'system_trash', + type: 'mount', + name: 'Trash', + mountId: 'system_trash', + parentId: null, + providerConfig: { + provider_type: 'system' + } + } + ] +} + +class NodeSeed implements ISeed { + async migrate(db: DbType): Promise { + const systemNodes = getSystemNodes() + const systemIds = systemNodes.map((n) => n.id) + + const existing = await db.select({ id: nodeTable.id }).from(nodeTable).where(inArray(nodeTable.id, systemIds)) + + const existingIds = new Set(existing.map((r) => r.id)) + const toInsert = systemNodes.filter((n) => !existingIds.has(n.id)) + + if (toInsert.length > 0) { + await db.insert(nodeTable).values(toInsert) + } + } +} + +export default NodeSeed diff --git a/src/main/data/utils/__tests__/pathResolver.test.ts b/src/main/data/utils/__tests__/pathResolver.test.ts new file mode 100644 index 00000000000..6e7ceca42ce --- /dev/null +++ b/src/main/data/utils/__tests__/pathResolver.test.ts @@ -0,0 +1,93 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import type { MountInfo, PathResolvableNode } from '../pathResolver' +import { getExtSuffix, resolvePhysicalPath } from '../pathResolver' + +describe('getExtSuffix', () => { + it('returns dot-prefixed extension for non-null ext', () => { + expect(getExtSuffix('pdf')).toBe('.pdf') + expect(getExtSuffix('md')).toBe('.md') + }) + + it('returns empty string for null ext', () => { + expect(getExtSuffix(null)).toBe('') + }) +}) + +describe('resolvePhysicalPath', () => { + describe('local_managed', () => { + const mount: MountInfo = { + providerConfig: { provider_type: 'local_managed', base_path: '/data/files' } + } + + it('returns {base_path}/{id}.{ext}', () => { + const node: PathResolvableNode = { id: 'abc-123', name: 'document', ext: 'pdf', mountId: 'mount_files' } + expect(resolvePhysicalPath(node, mount)).toBe(path.join('/data/files', 'abc-123.pdf')) + }) + + it('returns {base_path}/{id} with null ext', () => { + const node: PathResolvableNode = { id: 'abc-123', name: 'folder', ext: null, mountId: 'mount_files' } + expect(resolvePhysicalPath(node, mount)).toBe(path.join('/data/files', 'abc-123')) + }) + }) + + describe('local_external', () => { + const mount: MountInfo = { + providerConfig: { provider_type: 'local_external', base_path: '/data/notes', watch: true } + } + + it('returns {base_path}/{ancestors}/{name}.{ext}', () => { + const node: PathResolvableNode = { id: 'n1', name: 'readme', ext: 'md', mountId: 'mount_notes' } + const ancestors = ['project', 'docs'] + expect(resolvePhysicalPath(node, mount, ancestors)).toBe(path.join('/data/notes', 'project', 'docs', 'readme.md')) + }) + + it('returns {base_path}/{name}.{ext} with no ancestors', () => { + const node: PathResolvableNode = { id: 'n2', name: 'notes', ext: 'md', mountId: 'mount_notes' } + expect(resolvePhysicalPath(node, mount)).toBe(path.join('/data/notes', 'notes.md')) + }) + + it('returns path without ext when ext is null', () => { + const node: PathResolvableNode = { id: 'n3', name: 'subfolder', ext: null, mountId: 'mount_notes' } + expect(resolvePhysicalPath(node, mount)).toBe(path.join('/data/notes', 'subfolder')) + }) + }) + + describe('system', () => { + const mount: MountInfo = { + providerConfig: { provider_type: 'system' } + } + + it('throws error for system mount', () => { + const node: PathResolvableNode = { id: 'trash-1', name: 'Trash', ext: null, mountId: 'system_trash' } + expect(() => resolvePhysicalPath(node, mount)).toThrow('System mount nodes have no physical storage path') + }) + }) + + describe('remote', () => { + const mount: MountInfo = { + providerConfig: { + provider_type: 'remote', + api_type: 'openai_files', + provider_id: 'p1', + auto_sync: false, + options: {} + } + } + + it('throws error for remote mount (not yet implemented)', () => { + const node: PathResolvableNode = { id: 'r1', name: 'file', ext: 'txt', mountId: 'mount_remote' } + expect(() => resolvePhysicalPath(node, mount)).toThrow('not yet implemented') + }) + }) + + describe('edge cases', () => { + it('throws when mount has no provider config', () => { + const mount: MountInfo = { providerConfig: null } + const node: PathResolvableNode = { id: 'x', name: 'test', ext: 'txt', mountId: 'unknown' } + expect(() => resolvePhysicalPath(node, mount)).toThrow('has no provider config') + }) + }) +}) diff --git a/src/main/data/utils/pathResolver.ts b/src/main/data/utils/pathResolver.ts new file mode 100644 index 00000000000..d793554eda1 --- /dev/null +++ b/src/main/data/utils/pathResolver.ts @@ -0,0 +1,62 @@ +import path from 'node:path' + +import type { MountProviderConfig } from '@shared/data/types/fileProvider' + +/** + * Minimal node shape needed for path resolution + */ +export interface PathResolvableNode { + id: string + name: string + ext: string | null + mountId: string +} + +/** + * Mount info needed for path resolution + */ +export interface MountInfo { + providerConfig: MountProviderConfig | null +} + +/** + * Get the file extension suffix (with dot) or empty string if null + */ +export function getExtSuffix(ext: string | null): string { + return ext ? `.${ext}` : '' +} + +/** + * Resolve the physical filesystem path for a node. + * + * - `local_managed`: `{base_path}/{id}{.ext}` — flat UUID-based storage + * - `local_external`: `{base_path}/{...ancestorNames}/{name}{.ext}` — mirrors OS directory structure + * - `system`: throws — system mounts have no physical storage + * - `remote`: `{cache_path}/{remoteId}` — local cache path (future) + * + * @param node - The node to resolve + * @param mount - The mount this node belongs to + * @param ancestorNames - Ordered list of ancestor directory names from mount root to parent (only needed for local_external) + */ +export function resolvePhysicalPath(node: PathResolvableNode, mount: MountInfo, ancestorNames?: string[]): string { + const config = mount.providerConfig + if (!config) { + throw new Error(`Mount for node ${node.id} has no provider config`) + } + + switch (config.provider_type) { + case 'local_managed': + return path.join(config.base_path, `${node.id}${getExtSuffix(node.ext)}`) + + case 'local_external': { + const segments = ancestorNames ?? [] + return path.join(config.base_path, ...segments, `${node.name}${getExtSuffix(node.ext)}`) + } + + case 'system': + throw new Error('System mount nodes have no physical storage path') + + case 'remote': + throw new Error('Remote path resolution is not yet implemented') + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 26edbd1e45f..77576ea1923 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -155,6 +155,7 @@ if (!app.requestSingleInstanceLock()) { await dbService.init() await dbService.migrateDb() await dbService.migrateSeed('preference') + await dbService.migrateSeed('node') } catch (error) { logger.error('Failed to initialize database', error as Error) //TODO for v2 testing only: diff --git a/v2-refactor-temp/docs/file-manager/file-arch-problems.md b/v2-refactor-temp/docs/file-manager/file-arch-problems.md new file mode 100644 index 00000000000..cbb32129c14 --- /dev/null +++ b/v2-refactor-temp/docs/file-manager/file-arch-problems.md @@ -0,0 +1,91 @@ +# 现有文件管理架构的问题梳理 + +本文档记录当前版本文件管理架构中已识别的问题与风险,便于后续规划改造。 + +## 1. 职责边界割裂 + +- 物理文件由 main 进程落地与管理。 +- 逻辑引用与计数由 renderer 进程在 `db.files` 中维护。 +- 两者之间缺乏原子性与一致性保障,容易出现“文件已落地但引用未写入”或“引用存在但文件缺失”的状态。 + +## 2. 上传与登记非原子 + +- `uploadFile` 在 main 完成文件写入后,renderer 再写入 `db.files`。 +- `addFile` 可绕过 main 直接登记引用。 +- 这导致并发上传、异常中断时计数不准确或引用缺失。 + +## 3. 渲染进程可直接写入文件引用 + +- renderer 侧可直接调用 `addFile` 写入 `db.files`。 +- 若 `FileMetadata.path` 未经过 main 管理,可能指向不存在或不可访问的文件。 +- 这既是权限风险也是一致性风险。 + +## 4. 去重对用户可见性冲突 + +- 当前去重以“大小 + 内容 MD5”为准。 +- 对用户而言,同内容不同文件名无法被区分为不同文件。 +- 这与“用户视角文件应独立存在”的需求冲突。 + +## 5. 引用计数语义不足 + +- `count` 是引用计数,但引用关系本身并不显式。 +- 难以在迁移时精确还原“引用粒度”的文件节点。 +- 难以解释某个文件被哪些业务对象引用。 + +## 6. 缺少结构化目录树 + +- 当前文件列表以类型/时间/大小排序,不具备应用内目录树。 +- 若要引入目录树,需要重新定义“文件节点”与“目录节点”的关系。 + +## 7. 业务来源不可区分 + +- 知识库上传文件、对话上传文件统一落入扁平化存储。 +- 文件页面无法区分业务来源或上下文,缺少组织维度。 +- 笔记相关文件未纳入文件页面展示,导致可见性不一致。 + +## 8. 对话上传无法复用内部文件 + +- 在首页对话输入中,用户只能从 OS 选择文件上传。 +- 已上传到应用内部的文件无法直接在对话中引用或复用。 +- 造成重复上传与用户体验割裂。 + +## 9. 笔记文件管理与全局文件管理割裂 + +- 笔记文件树独立管理,未纳入 `db.files` 体系。 +- 与对话/知识库等文件管理路径完全分离,缺少统一入口与一致性保障。 +- 对用户而言,文件能力表现不一致(文件页不可见、来源不可追溯)。 + +## 10. 笔记文件树未纳入 DB 管理 + +优点: + +- 直接映射真实文件系统,外部编辑器可无缝协作。 +- 无需额外索引或迁移,结构简单。 +- 变更监听可直接基于目录扫描与文件监控。 + +问题: + +- 与 `db.files` 体系割裂,无法统一检索与展示。 +- 业务维度难以叠加(来源、标签、引用关系)。 +- 一致性依赖监听与扫描,逻辑分散在页面中。 +- 未来引入“单一节点表 + 文件树”需要重新建模或双向同步。 + +## 11. 跨进程一致性难以验证 + +- 主进程与渲染进程缺少统一的“文件写入 + 引用登记”事务。 +- 目前也缺少统一的校验或修复机制(如启动时对齐磁盘与 DB)。 + +## 12. 可扩展性受限 + +- 现有 `FileMetadata` 结构与 `db.files` 表不易扩展到“单一节点表 + 文件树”模型。 +- 迁移需要同时处理物理文件、引用计数、业务引用数据(messages/knowledge/paintings)。 + +## 13. FileMetadata 生产不统一 + +- `ext`/`type` 的生成分散在多个入口,存在不一致策略。 +- main 侧通过扩展名与文本检测推断类型,renderer 侧多处直接使用 MIME 或字符串拼接。 +- 缺少统一入口与规范,容易导致展示与过滤行为不一致。 + +## 结语 + +以上问题说明当前架构更像“物理存储 + 轻量引用计数”的混合实现,后续若引入“单一节点表 + 文件树”,需要明确主进程统一入口与引用粒度的迁移策略。 diff --git a/v2-refactor-temp/docs/file-manager/file-management.md b/v2-refactor-temp/docs/file-manager/file-management.md new file mode 100644 index 00000000000..a38b4499549 --- /dev/null +++ b/v2-refactor-temp/docs/file-manager/file-management.md @@ -0,0 +1,111 @@ +# 文件管理说明(现有实现) + +本文档描述 Cherry Studio 现有版本的文件管理机制,覆盖主进程文件存储、渲染进程文件引用、IPC 接口与 UI 行为。 + +## 总览 + +- 文件真实内容由主进程统一落地到应用资源目录。 +- 渲染进程以 `FileMetadata` 作为业务载体,元信息与引用计数存于 Dexie (`db.files`)。 +- 文件去重基于“大小 + 内容 MD5”,发生在主进程上传阶段。 +- UI 侧文件列表与附件展示使用 `db.files` 数据,`count` 用于引用计数显示与删除策略。 + +## 目录与存储位置 + +- 文件目录:由 `getFilesDir()` 计算,主进程写入。 +- 笔记目录:由 `getNotesDir()` 计算。 +- 临时目录:由 `getTempDir()` 计算。 + +主进程初始化这些目录:`src/main/services/FileStorage.ts`。 + +## 数据模型(渲染进程) + +`FileMetadata`(`src/renderer/src/types/file.ts`)核心字段: + +- `id`: 文件 ID(UUID) +- `name`: 存储文件名(通常是 `uuid + ext`) +- `origin_name`: 原始文件名(用于展示与重命名) +- `path`: 原始路径或构造路径 +- `size`: 文件大小 +- `ext`: 扩展名(包含点) +- `type`: 文件类型(image/document/text/...) +- `created_at`: 创建时间 +- `count`: 引用计数 + +Dexie 表定义:`src/renderer/src/databases/index.ts`,`files` 表包含上述字段与索引。 + +## 主进程文件服务(FileStorage) + +文件存储与处理:`src/main/services/FileStorage.ts`。 + +主要能力: + +- 选择/保存文件(系统对话框) +- 上传(复制到资源目录) +- 删除/移动/重命名文件与目录 +- 读取文件内容(含 office/pdf 解析与编码检测) +- Base64/二进制读取 +- 目录扫描与搜索(内置 ripgrep) +- 文件监听(chokidar) + +### 去重策略 + +- 上传前先比对文件大小,再比对内容 MD5。 +- MD5 输入为文件的完整字节流(`fs.createReadStream`)。 +- 命中重复时返回已存文件的 `FileMetadata`,并在渲染进程侧增加 `count`。 + +## 渲染进程文件服务(FileManager) + +文件引用与计数:`src/renderer/src/services/FileManager.ts`。 + +核心行为: + +- `uploadFile(s)`: 调用 IPC 上传,若已有记录则 `count + 1`。 +- `addFile(s)`: 直接写入 `db.files`,已有则 `count + 1`。 +- `deleteFile`: 当 `count > 1` 时仅减计数,不删物理文件;否则删除 `db.files` 并调用主进程删除。 +- `getFilePath` / `getFileUrl`: 基于 `app.path.files` 构造路径或 `file://` URL。 +- `formatFileName`: 使用 `origin_name` 进行展示处理。 + +## IPC 接口(文件相关) + +注册位置:`src/main/ipc.ts`;预加载暴露:`src/preload/index.ts`(`window.api.file`)。 + +常用接口示例: + +- `File_Select` / `File_Open` / `File_Save` +- `File_Upload` / `File_Delete` / `File_Move` / `File_Rename` +- `File_Read` / `File_ReadExternal` +- `File_Base64Image` / `File_Base64File` / `File_BinaryImage` +- `File_ListDirectory` / `File_GetDirectoryStructure` +- `File_StartWatcher` / `File_StopWatcher` + +## UI 与业务使用点 + +- 文件列表页:`src/renderer/src/pages/files/FilesPage.tsx`、`FileList.tsx` + - 读取 `db.files`,按类型/时间/大小/名称排序。 + - 展示 `count`(引用次数)。 +- 消息附件:输入框上传、消息块展示。 +- 绘图与知识库:使用 `FileManager.addFiles` 或 `uploadFiles` 写入 `db.files`。 + +## 引用计数(count)语义 + +`count` 是文件被引用的次数,用于: + +- 删除策略:`count > 1` 时仅减计数,不删除物理文件。 +- UI 展示:文件列表显示引用次数。 +- 消息/块删除时,`DexieMessageDataSource.updateFileCount` 会更新计数并在归零时删除。 + +## 注意事项与限制 + +- 去重对用户不可见,但当前实现会导致同内容文件被视为同一记录,`origin_name` 被覆盖或共享。 +- `name` 与实际存储文件名绑定,用于定位文件;`origin_name` 仅用于展示与重命名。 +- 文档解析(office/pdf)在主进程进行,可能受格式或编码影响。 +- 文件监听仅对指定扩展名生效(默认 `md/markdown/txt`)。 + +## 关键文件索引 + +- `src/main/services/FileStorage.ts` +- `src/main/ipc.ts` +- `src/preload/index.ts` +- `src/renderer/src/services/FileManager.ts` +- `src/renderer/src/databases/index.ts` +- `src/renderer/src/pages/files/FilesPage.tsx` diff --git a/v2-refactor-temp/docs/file-manager/notes-file-tree.md b/v2-refactor-temp/docs/file-manager/notes-file-tree.md new file mode 100644 index 00000000000..57c4d350a0d --- /dev/null +++ b/v2-refactor-temp/docs/file-manager/notes-file-tree.md @@ -0,0 +1,64 @@ +# 笔记功能的文件树处理方式(现有实现) + +本文档说明当前版本笔记功能如何管理文件树,包括存储位置、树结构加载、文件监听与文件操作路径。 + +## 存储位置与路径来源 + +- 笔记根目录来自 `window.api.getAppInfo().notesPath`。 +- `notesPath` 存在于 Redux(`store/note`),通过 `useNotesSettings` 读写。 +- main 侧默认目录由 `getNotesDir()` 创建。 + +## 文件树的构建与刷新 + +- 页面加载后调用 `loadTree(notesPath)` 获取目录结构。 +- `loadTree` 通过 `window.api.file.getDirectoryStructure` 读取真实文件系统树。 +- `sortTree` 按用户设置排序(A-Z、更新时间等)。 +- `mergeTreeState` 会把 `starredPaths` 与 `expandedPaths` 合并回树节点状态。 + +相关实现: + +- `src/renderer/src/pages/notes/NotesPage.tsx` +- `src/renderer/src/services/NotesService.ts` +- `src/renderer/src/services/NotesTreeService.ts` + +## 文件监听与同步 + +- 进入笔记页后,通过 `window.api.file.startFileWatcher(notesPath)` 启动监听。 +- 监听事件来自 main 进程 `FileStorage` 的 chokidar watcher。 +- 发生变更时触发 tree refresh,并更新 starred/expanded 状态。 + +## 文件与目录操作 + +- 新建目录:`addDir` -> `window.api.file.mkdir`。 +- 新建笔记:`addNote` -> `window.api.file.write`(写入 `.md`)。 +- 删除节点:`delNode` -> `deleteExternalFile` / `deleteExternalDir`。 +- 重命名:`renameNode` -> `file.rename` / `file.renameDir`。 + +所有操作直接作用于文件系统,不经过 `db.files`。 + +## 上传处理 + +笔记只接受 Markdown 文件(`.md`, `.markdown`)。 + +上传路径: + +- 拖拽或选择文件 -> `useNotesFileUpload` 收集文件列表。 +- `uploadNotes` 优先使用 main 侧批量上传: + - `window.api.file.batchUploadMarkdown(filePaths, targetPath)` + - 上传前暂停 watcher,上传后恢复并刷新。 +- 若文件没有路径(浏览器 File API),回退到 renderer 逐个写入(`uploadNotesLegacy`)。 + +## 内容保存策略 + +- 编辑器内容通过防抖写入:`window.api.file.write(targetPath, content)`。 +- 写入后刷新缓存,确保下次读取到最新内容。 + +## 与文件页面的关系 + +- 笔记文件树完全基于文件系统,不写入 `db.files`。 +- 文件页面(`/files`)仅展示 `db.files` 中的记录,因此不会显示笔记文件。 + +## 限制与现状 + +- 笔记文件树依赖文件系统扫描与 watcher,同步逻辑分散在页面内。 +- 与 `db.files` 的文件引用体系割裂,难以统一检索或跨业务归档。 diff --git a/v2-refactor-temp/docs/file-manager/rfc-file-manager.md b/v2-refactor-temp/docs/file-manager/rfc-file-manager.md new file mode 100644 index 00000000000..1a0e9377ebf --- /dev/null +++ b/v2-refactor-temp/docs/file-manager/rfc-file-manager.md @@ -0,0 +1,1721 @@ +# RFC: 文件管理(单一节点表 + 文件树) + +## 一、背景 + +现有问题参见 `./file-arch-problems.md`,核心矛盾包括: + +- 职责边界割裂与一致性风险(问题 1/2/3/11)。 +- 去重机制与用户可见性冲突(问题 4)。 +- 缺少结构化目录树与统一检索入口(问题 6/10)。 +- 业务来源不可区分、引用关系不显式(问题 5/7)。 +- 笔记体系与全局文件管理割裂(问题 9)。 +- 元数据生成多入口导致策略不一致(问题 13)。 + +因此需要以"单一节点表 + 文件树 + 引用关系"作为新的文件管理基础,以解决上述结构性问题。 + +--- + +## 二、范畴说明 + +- 本 RFC 覆盖节点模型、文件树结构、存储模式、数据 Schema、核心流程、API 设计、引用清理、迁移策略与分阶段实施计划。 +- 不涉及 UI 交互与具体页面改动。 +- 不讨论元信息编辑(如改名、标签)之外的业务流程变更。 + +补充说明: + +- 对话内复用应用内部文件的交互不在本 RFC 范围内,但在实现文件树后应可通过业务流程接入。(问题 8) +- 笔记体系需要迁移到本文件树架构之下,纳入统一节点表与引用关系。(问题 9/10) +- `FileMetadata` 等元数据生成应收口到统一工厂入口,避免多入口策略不一致。(问题 13) +- Painting 业务重构不在本次范围内,仅依赖 FileMigrator 提供的 fileId,随 Painting 重构独立推进。 + +--- + +## 三、设计目标 + +- 统一主进程入口与一致性保障,消除职责边界割裂。(问题 1/2/3/11) +- 放弃去重,让用户视角文件保持独立。(问题 4) + - 可保留重复文件检查,由用户决定具体行为。 +- 以单一节点表表达文件与目录,并形成可检索的目录树。(问题 6/10) +- 用显式引用关系表达业务使用情况与来源。(问题 5/7) +- 为笔记体系与对话复用提供结构基础。(问题 8/9) +- 为迁移与扩展提供清晰的演进方向。(问题 12/13) + +--- + +## 四、双模式存储设计 + +### 4.1 问题 + +"节点应当保证与 OS 文件系统的目录结构保持一致"与"底层存储文件名为 `id + ext`"不可兼得。笔记系统必须使用人类可读文件名以支持外部编辑器(VS Code、Obsidian)。 + +### 4.2 方案:Provider 驱动的挂载点 + +引入**挂载点(Mount)**概念,每个挂载点定义一种存储模式: + +| 挂载点 | provider_type | 物理文件名 | Source of Truth | 同步方向 | +|--------|-------------|-----------|----------------|---------| +| Files | `local_managed` | `{id}.{ext}` | DB | App → 文件系统 | +| Notes | `local_external` | `{name}.{ext}` | 文件系统 | 文件系统 ↔ DB | +| Trash | `system` | 无自有存储 | DB | — | +| *(未来)* | `remote` | `{cache_path}/{remote_id}` | 远程 API | 远程 ↔ 本地缓存 ↔ DB | + +### 4.3 路径计算规则 + +节点的物理路径由挂载点的 `provider_type` 决定: + +- **`local_managed`**:`{mount.base_path}/{node.id}.{node.ext}`(平坦存储,目录仅逻辑) +- **`local_external`**:`{mount.base_path}/{...ancestor_names}/{node.name}.{node.ext}`(映射 OS 目录树) +- **`remote`**:`{mount.cache_path}/{node.remoteId}`(本地缓存),实际文件通过 API 访问,按需下载/同步 + +`path` 不作为持久化字段,运行时由树关系与挂载点配置构建。建议维护内存级路径缓存 `Map`,树变更时重建。 + +#### `ext` 格式规范 + +- 节点表中 `ext` 统一存储为**不含前导点**的格式(如 `'pdf'`、`'md'`、`'png'`)。 +- 迁移时对旧数据执行 `ext.replace(/^\./, '')` 做一次性 normalize。 +- `resolvePhysicalPath` 拼接时**始终加点**:`${id}.${ext}`。 +- 若 `ext` 为 `null`(目录/挂载点),路径无扩展名。 + +```typescript +/** + * 解析节点的物理文件路径。 + * 外部调用时仅传 node,mount 由内部通过 node.mountId 查询。 + * 内部实现接受可选的 mount 参数以避免重复查询。 + */ +function resolvePhysicalPath(node: FileNode, mount?: FileNode): string { + const resolvedMount = mount ?? getMountNode(node.mountId) // 内存缓存查询 + const extSuffix = node.ext ? `.${node.ext}` : '' + + switch (resolvedMount.providerConfig?.provider_type) { + case 'local_managed': + return path.join(resolvedMount.providerConfig.base_path, `${node.id}${extSuffix}`) + case 'local_external': + const ancestors = getAncestorNames(node, resolvedMount) // 不含 mount 自身 + return path.join(resolvedMount.providerConfig.base_path, ...ancestors, `${node.name}${extSuffix}`) + case 'system': + // system mount(如 Trash)无自有物理存储 + // Trash 中的文件路径由其原始 mountId 对应的 mount 决定 + throw new Error('System mount has no physical storage. Use original mount to resolve path.') + default: + throw new Error(`Unknown provider type: ${resolvedMount.providerConfig?.provider_type}`) + } +} +``` + +> **Trash 中文件的路径解析**:Trash 中的节点保持原 `mountId` 不变,因此解析路径时应使用原 `mountId` 对应的 mount,而非 `system_trash`。对于 `local_managed` 模式,物理文件位置不变(UUID 命名);对于 `local_external` 模式,物理文件已移至 `{原mount.base_path}/.trash/{nodeId}/`。 + +### 4.4 各模式同步策略 + +#### local_external 模式(Notes) + +文件系统为 Source of Truth,节点表作为索引层: + +``` + ┌──────────────────┐ + │ 文件系统 (SoT) │ + └────────┬─────────┘ + │ + ┌──────────────┼──────────────┐ + ▼ ▼ ▼ + chokidar 启动时扫描 手动刷新 + (增量事件) (全量 reconcile) (用户触发) + │ │ │ + └──────────────┼──────────────┘ + ▼ + ┌──────────────────┐ + │ 节点表 (索引层) │ + └──────────────────┘ +``` + +- **启动时**:全量扫描文件系统 → diff 节点表 → 增删改对齐 +- **运行时**:chokidar 监听 → 防抖 → 增量更新节点表 + - chokidar 必须排除 `.trash` 目录:`ignored: ['**/.trash/**']` +- **冲突处理**:文件系统 wins +- **操作锁**:应用内发起的文件系统变更(trash / restore / move / rename)必须在操作期间挂起 chokidar 事件处理,避免同步引擎将应用自身的操作误判为外部变更。实现方式:维护 `Set` 记录"正在操作中的路径",同步引擎在处理事件前检查该集合,命中则跳过。操作完成后移除路径。 + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ FileNodeSvc │ │OperationLock │ │ SyncEngine │ +│ trashNode() │ │ Set │ │ (chokidar) │ +└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ + │ 1. lock(oldPath) │ │ + │──────────────────────→│ │ + │ 2. fs.rename() │ │ + │───────────────────────┼──────────────────────→│ unlink(oldPath) + │ │ 3. check lock │ + │ │←──────────────────────│ + │ │ 4. "locked, skip" │ + │ │──────────────────────→│ (ignored) + │ 5. db.update() │ │ + │ 6. unlock(oldPath) │ │ + │──────────────────────→│ │ +``` + +#### remote 模式(远程文件 API) + +远程 API 为 Source of Truth,本地缓存 + 节点表作为镜像层: + +``` + ┌──────────────────┐ + │ 远程 API (SoT) │ + └────────┬─────────┘ + │ + ┌──────────────┼──────────────┐ + ▼ ▼ ▼ + 轮询/API推送 启动时全量同步 用户手动刷新 + (增量事件) (list + diff) (触发同步) + │ │ │ + └──────────────┼──────────────┘ + ▼ + ┌──────────────────┐ + │ 本地缓存 │ + │ {cache_path} │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ 节点表 (镜像层) │ + └──────────────────┘ +``` + +- **启动时**:调用远程 API list files → diff 本地节点表 → 增删改对齐 +- **运行时**:轮询或 webhook 接收变更 → 下载/更新本地缓存 → 更新节点表 +- **本地访问**:优先读本地缓存,`cachedAt` 非 null 时直接返回缓存路径(可与远程 `updatedAt` 比较判断缓存是否过期);未缓存时按需下载 +- **冲突处理**:远程 wins(本地修改后需显式上传) +- **离线支持**:缓存文件可离线访问,联网后同步变更 + +#### 跨 Provider 文件引用 + +当需要在本地引用远程文件时(如用 Provider B 处理 Provider A 的文件),流程如下: + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Provider A │ │ 本地缓存 │ │ Provider B │ +│ (remote mount) │ ──────→ │ (managed mount) │ ──────→ │ (remote mount) │ +│ │ 下载 │ │ 上传 │ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + ↑ │ + └──────────────── file_ref ──────────────────────────┘ + sourceType='chat_message' + role='attachment' + nodeId = 本地缓存节点ID +``` + +**处理流程**: + +1. **用户选择远程文件**:从 `mount_openai` 选择 `file-abc123` +2. **创建本地副本**:下载到 `mount_files`(managed),生成新节点 `local-copy-xyz` +3. **建立引用**:创建 `file_ref`(`nodeId=local-copy-xyz`, `sourceType='chat_message'`) +4. **发送给 Provider B**:读取本地缓存路径 → 上传 → 获取 Provider B 的 `file_id` +5. **清理**:消息发送完成后,根据策略保留或删除本地副本 + +**设计要点**: + +- 本地副本作为"暂存区",生命周期与业务对象绑定 +- 通过 `file_ref` 追踪"哪个消息引用了哪个本地副本" +- 本地副本存储在 `mount_files`(managed),享受统一的 Trash/清理机制 +- 可选:标记 `isTemporary=true`,在引用清理时自动删除 + +--- + +## 五、Provider 配置类型定义 + +### 5.1 Zod Schema + +采用 **Base + options 泛型字段** 模式:公共字段强类型,Provider 专属配置放在 `options` 中由具体实现二次验证。 + +```typescript +import * as z from 'zod' + +// ─── Provider Type 枚举 ─── +export const MountProviderTypeSchema = z.enum([ + 'local_managed', + 'local_external', + 'remote', + 'system', // 系统内部挂载点(如 Trash),无物理存储 +]) +export type MountProviderType = z.infer + +// ─── Remote API 类型枚举(可扩展)─── +export const RemoteApiTypeSchema = z.enum([ + 'openai_files', + // 未来: 's3', 'webdav', 'google_drive', ... +]) +export type RemoteApiType = z.infer + +// ─── 各 Provider 的 Config Schema ─── + +/** 托管文件:应用内部管理,UUID 命名 */ +export const LocalManagedConfigSchema = z.object({ + provider_type: z.literal('local_managed'), + base_path: z.string().min(1), +}) + +/** 外部文件:文件系统为主,人类可读命名 */ +export const LocalExternalConfigSchema = z.object({ + provider_type: z.literal('local_external'), + base_path: z.string().min(1), + watch: z.boolean().default(true), + watch_extensions: z.array(z.string()).optional(), +}) + +/** 远程文件:通过 API 访问 */ +export const RemoteConfigSchema = z.object({ + provider_type: z.literal('remote'), + api_type: RemoteApiTypeSchema, + provider_id: z.string().min(1), // 关联 AI provider 配置(不存敏感信息) + cache_path: z.string().optional(), + auto_sync: z.boolean().default(false), + options: z.record(z.string(), z.unknown()).default({}), // 各 API 专属配置 +}) + +/** 系统内部挂载点:无物理存储,仅用于组织结构(如 Trash) */ +export const SystemConfigSchema = z.object({ + provider_type: z.literal('system'), +}) + +// ─── 判别联合 ─── +export const MountProviderConfigSchema = z.discriminatedUnion('provider_type', [ + LocalManagedConfigSchema, + LocalExternalConfigSchema, + RemoteConfigSchema, + SystemConfigSchema, +]) +export type MountProviderConfig = z.infer +``` + +### 5.2 远程 Provider 扩展模式 + +`RemoteConfigSchema.options` 是泛型 Record,具体 Provider 实现时二次验证: + +```typescript +// 示例:OpenAI Files Provider 实现中 +const OpenAIFilesOptionsSchema = z.object({ + purpose_filter: z.array(z.string()).optional(), + org_id: z.string().optional(), +}) + +class OpenAIFilesProvider implements RemoteProvider { + constructor(config: RemoteConfig) { + this.options = OpenAIFilesOptionsSchema.parse(config.options) + } +} +``` + +核心 Schema 不需要因新增 Provider 而变更。 + +--- + +## 六、数据模型(Drizzle Schema) + +### 6.1 设计决策汇总 + +| 决策项 | 结论 | 理由 | +|--------|------|------| +| 挂载点与节点同表/分表 | **同表** | 挂载点极少(2-5 个),字段浪费可忽略 | +| managed 模式目录 | **平坦存储** | 物理无子目录,目录仅逻辑存在于 DB | +| 主键策略 | **UUID v7**(时间有序) | 大数据量表,顺序插入性能更优 | +| 删除策略 | **OS 风格 Trash** | 移动到 Trash mount 下,记录 previousParentId | +| Trash 节点类型 | **mount(`provider_type: 'system'`)** | 与其他顶层节点模式统一,`type='mount'` + `mountId=self` + `parentId=null`。`getMounts()` 通过 `includeSystem` 参数区分 | +| `mountId` 冗余字段 | **保留** | 避免递归 CTE 查挂载点,查询性能关键 | +| `parentId` 级联删除 | **CASCADE** | Trash 清空时物理级联删除子节点 | +| `sourceType`/`role` 枚举约束 | **应用层 Zod 验证** | 避免新增来源需要 migration | +| `file_ref` 防重复 | **UNIQUE 约束** | 同一业务对象以同一角色引用同一文件至多一条记录,防止应用层 bug 导致重复引用。业务语义:一条消息不会以 `attachment` 角色引用同一个文件两次——如需附加同一文件多次,应创建文件副本(独立 nodeId) | + +### 6.2 nodeTable + +```typescript +import type { MountProviderConfig } from '@shared/data/types/fileNode' +import { sql } from 'drizzle-orm' +import { check, foreignKey, index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' + +import { createUpdateTimestamps, uuidPrimaryKeyOrdered } from './_columnHelpers' + +/** + * Node table - unified file/directory/mount node entity + * + * Uses adjacency list pattern (parentId) for tree navigation. + * Mount nodes (type='mount') serve as root nodes with provider configuration. + * Trash is a system mount node (provider_type='system') for OS-style soft deletion. + */ +export const nodeTable = sqliteTable( + 'node', + { + id: uuidPrimaryKeyOrdered(), + + // ─── 核心字段 ─── + // 节点类型:file | dir | mount + type: text().notNull(), + // 用户可见名称(不含扩展名) + name: text().notNull(), + // 扩展名,不含前导点(如 'pdf'、'md')。目录/挂载点为 null + ext: text(), + + // ─── 树结构 ─── + // 父节点 ID。挂载点为 null(顶层) + parentId: text(), + // 所属挂载点 ID(冗余,便于查询)。挂载点自身 mountId = id + // Trash 中的节点保持原 mountId 不变(指向被删前所属的挂载点) + mountId: text().notNull(), + + // ─── 文件属性 ─── + // 文件大小(字节)。目录/挂载点为 null + size: integer(), + + // ─── 挂载点专属(仅 type='mount')─── + // Provider 配置 JSON,经 MountProviderConfigSchema 验证 + providerConfig: text({ mode: 'json' }).$type(), + // 是否只读(远程源可能只读) + isReadonly: integer({ mode: 'boolean' }).default(false), + + // ─── 远程文件预留(仅远程挂载点下的文件)─── + // 远程端文件 ID(如 OpenAI file-abc123) + remoteId: text(), + // 本地缓存最后下载时间(ms epoch)。null 表示未缓存。 + // 与远程 updatedAt 比较可判断缓存是否过期。 + cachedAt: integer(), + + // ─── Trash 相关 ─── + // 被移入 Trash 前的原始父节点 ID(仅 Trash 直接子节点有值) + previousParentId: text(), + + // ─── 时间戳 ─── + ...createUpdateTimestamps + }, + (t) => [ + // 自引用外键:删除父节点时级联删除子节点(Trash 清空时生效) + foreignKey({ columns: [t.parentId], foreignColumns: [t.id] }) + .onDelete('cascade'), + // 索引 + index('node_parent_id_idx').on(t.parentId), + index('node_mount_id_idx').on(t.mountId), + index('node_mount_type_idx').on(t.mountId, t.type), + index('node_name_idx').on(t.name), + index('node_updated_at_idx').on(t.updatedAt), + // 类型约束 + check('node_type_check', sql`${t.type} IN ('file', 'dir', 'mount')`), + ] +) +``` + +### 6.3 fileRefTable + +```typescript +/** + * File reference table - tracks which business entities reference which files + * + * Polymorphic association: sourceType + sourceId identify the referencing entity. + * No FK constraint on sourceId (polymorphic). Application-layer cleanup required + * when source entities are deleted. + * + * nodeId has CASCADE delete: removing a file node auto-removes its references. + */ +export const fileRefTable = sqliteTable( + 'file_ref', + { + id: uuidPrimaryKey(), + + // 引用的文件节点 ID + nodeId: text() + .notNull() + .references(() => nodeTable.id, { onDelete: 'cascade' }), + + // 业务来源类型(如 'chat_message', 'knowledge_item', 'painting', 'note') + // 枚举由应用层 Zod 验证,不设 CHECK 约束 + sourceType: text().notNull(), + // 业务对象 ID(多态,无 FK 约束) + sourceId: text().notNull(), + // 引用角色(如 'attachment', 'source', 'asset') + role: text().notNull(), + + ...createUpdateTimestamps + }, + (t) => [ + // 从文件查引用方 + index('file_ref_node_id_idx').on(t.nodeId), + // 从业务对象查引用的文件 + index('file_ref_source_idx').on(t.sourceType, t.sourceId), + // 防止重复引用(同一文件不能被同一业务对象以同一角色引用两次) + uniqueIndex('file_ref_unique_idx').on(t.nodeId, t.sourceType, t.sourceId, t.role), + ] +) +``` + +### 6.4 系统初始化节点 + +应用首次启动时创建以下系统节点: + +```typescript +const SYSTEM_NODES = [ + { + id: 'mount_files', // 固定 ID + type: 'mount', + name: 'Files', + mountId: 'mount_files', // 自引用 + providerConfig: { + provider_type: 'local_managed', + base_path: getFilesDir(), // {userData}/Data/Files + }, + }, + { + id: 'mount_notes', + type: 'mount', + name: 'Notes', + mountId: 'mount_notes', + providerConfig: { + provider_type: 'local_external', + base_path: getNotesDir(), // 用户配置 或 {userData}/Data/Notes + watch: true, + }, + }, + { + id: 'system_trash', + type: 'mount', + name: 'Trash', + mountId: 'system_trash', // 自引用,与其他 mount 一致 + parentId: null, + providerConfig: { + provider_type: 'system', // 系统挂载点,无物理存储 + }, + }, +] +``` + +### 6.5 DTO 类型定义 + +位于 `packages/shared/data/types/fileNode.ts`。 + +```typescript +/** 节点实体 */ +interface FileNode { + id: string + type: 'file' | 'dir' | 'mount' + name: string + ext: string | null + parentId: string | null + mountId: string + size: number | null + providerConfig: MountProviderConfig | null + isReadonly: boolean + remoteId: string | null + cachedAt: number | null + previousParentId: string | null + createdAt: number + updatedAt: number +} + +/** 创建节点 */ +interface CreateNodeDto { + type: 'file' | 'dir' + name: string + ext?: string + parentId: string + mountId: string + size?: number +} + +/** 更新节点 */ +interface UpdateNodeDto { + name?: string + ext?: string +} + +/** 文件引用实体 */ +interface FileRef { + id: string + nodeId: string + sourceType: string + sourceId: string + role: string + createdAt: number + updatedAt: number +} + +/** 创建引用 */ +interface CreateFileRefDto { + sourceType: string + sourceId: string + role: string +} +``` + +--- + +## 七、核心流程 + +### 7.1 统一入口与一致性要求(问题 1/2/3/11) + +- 通过统一入口由 main 管理所有落地写入,renderer 仅通过 DataApi 交互。 +- main 进程负责"物理写入 + 节点登记"的原子性保障。 +- 任何失败都必须回滚物理文件或节点记录,避免半成状态。 +- renderer 不得绕过 main 直接写入节点表。 + +### 7.2 上传 + +``` +1. Renderer: window.api.file.upload(file) ← 复用现有 IPC(物理文件传输) +2. Main: FileStorage 写入物理文件 ← 复用现有逻辑 +3. Main: FileNodeService.create(nodeDto) ← 新增:写入节点表 +4. Main: 返回 FileNode ← 替代原有 FileMetadata +``` + +上传是原子操作:物理文件写入 + 节点创建在同一个 main 进程事务中完成。 + +补充约束: + +- 放弃去重,不做内容去重。允许同名文件存在。 +- `local_managed` 模式:底层存储文件名为 `id + ext`,不与用户可见名称冲突。 +- `local_external` 模式:存储文件名为 `name + ext`,映射 OS 目录结构。 + +### 7.3 删除与恢复(OS 风格 Trash) + +参照 Windows/macOS 回收站行为: + +- **删除 = 移动到 Trash 目录下**(不是原地标记) +- **恢复 = 移回 `previousParentId`** +- **永久删除 = 从 Trash 中硬删**(CASCADE 级联子节点 + 物理文件清理) + +#### Provider 模式对物理文件的影响 + +不同存储模式下,Trash 操作对物理文件的处理不同: + +| 操作 | `local_managed` | `local_external` | +|------|-----------------|-------------------| +| **Trash(软删)** | 仅 DB 操作(物理文件名为 UUID,用户不可见) | **物理移动**到 `{mount.base_path}/.trash/{nodeId}/` | +| **Restore(恢复)** | 仅 DB 操作 | **物理移回**原路径 | +| **永久删除** | 删物理文件 + 删 DB | 删 `.trash` 中的物理文件 + 删 DB | + +**为什么 `local_external` 必须移动物理文件**:external 模式的目录对用户可见(如 Notes 文件夹),如果 Trash 只做 DB 标记而不移动物理文件,用户在 Finder/文件管理器中仍能看到"已删除"的文件,造成应用状态与文件系统不一致的困惑。 + +**`.trash` 目录规范**: + +- 位置:`{mount.base_path}/.trash/`(以 `.` 开头,在 macOS/Linux 下默认隐藏) +- 内部结构:`{mount.base_path}/.trash/{nodeId}/{原始相对路径}`(用 nodeId 做隔离,避免不同删除批次的同名文件冲突) +- chokidar 监听必须排除 `.trash` 目录(`ignored: ['**/.trash/**']`) +- 首次 Trash 操作时自动创建 `.trash` 目录 + +#### 删除节点 + +```typescript +const SYSTEM_NODE_IDS = new Set(['mount_files', 'mount_notes', 'system_trash']) + +async function trashNode(nodeId: string): Promise { + // 系统节点不可删除 + if (SYSTEM_NODE_IDS.has(nodeId)) { + throw new Error('System nodes cannot be trashed.') + } + + const node = await getNode(nodeId) + + // 挂载点节点不可删除 + if (node.type === 'mount') { + throw new Error('Mount nodes cannot be trashed.') + } + + const mount = await getNode(node.mountId) + + // local_external 模式:先移动物理文件到 .trash + if (mount.providerConfig?.provider_type === 'local_external') { + const physicalPath = resolvePhysicalPath(node) + const trashPath = path.join( + mount.providerConfig.base_path, + '.trash', + nodeId, + path.relative(mount.providerConfig.base_path, physicalPath), + ) + await fs.ensureDir(path.dirname(trashPath)) + await fs.rename(physicalPath, trashPath) + // 如果是目录,整个子树的物理文件都随 fs.rename 一起移走了 + } + + // 更新 DB:移动到 Trash 下,记录原始位置 + await db.update(nodeTable) + .set({ + parentId: SYSTEM_TRASH_ID, + previousParentId: node.parentId, + }) + .where(eq(nodeTable.id, nodeId)) + // 子节点不需要移动 — 它们的 parentId 仍然指向被移动的节点 + // 整棵子树自然跟随父节点进入 Trash +} +``` + +#### 恢复节点 + +```typescript +async function restoreNode(nodeId: string): Promise { + const node = await getNode(nodeId) + + // 检查原始父节点是否存在且可达 + const originalParent = await getNode(node.previousParentId) + if (!originalParent || isInTrash(originalParent)) { + throw new Error('Original parent is in Trash. Restore parent first.') + } + + const mount = await getNode(node.mountId) + + // local_external 模式:将物理文件从 .trash 移回原位 + if (mount.providerConfig?.provider_type === 'local_external') { + // 计算恢复后的物理路径(基于 previousParentId 的祖先链) + const restoredPath = resolvePhysicalPath({ ...node, parentId: node.previousParentId }) + const trashPath = path.join( + mount.providerConfig.base_path, + '.trash', + nodeId, + path.relative(mount.providerConfig.base_path, restoredPath), + ) + + // 检查目标路径冲突 + if (await fs.pathExists(restoredPath)) { + throw new Error(`Cannot restore: path already exists at ${restoredPath}`) + } + + await fs.ensureDir(path.dirname(restoredPath)) + await fs.rename(trashPath, restoredPath) + + // 清理空的 .trash/{nodeId} 目录 + await fs.remove(path.join(mount.providerConfig.base_path, '.trash', nodeId)) + } + + await db.update(nodeTable) + .set({ + parentId: node.previousParentId, + previousParentId: null, + }) + .where(eq(nodeTable.id, nodeId)) +} +``` + +#### 永久删除(清空回收站) + +```typescript +async function permanentDelete(nodeId: string): Promise { + // 系统节点 / 挂载点不可永久删除 + if (SYSTEM_NODE_IDS.has(nodeId)) { + throw new Error('System nodes cannot be permanently deleted.') + } + const node = await getNode(nodeId) + if (node.type === 'mount') { + throw new Error('Mount nodes cannot be permanently deleted.') + } + + const mount = await getNode(node.mountId) + + if (mount.providerConfig?.provider_type === 'local_external') { + // external 模式:物理文件已在 .trash/{nodeId}/ 中,直接删除整个目录 + const trashDir = path.join(mount.providerConfig.base_path, '.trash', nodeId) + await fs.remove(trashDir).catch(() => {}) + } else { + // managed 模式:收集所有后代的物理文件路径 + const descendants = await getDescendants(nodeId) // 递归 CTE + const filesToDelete = descendants + .filter(n => n.type === 'file') + .map(n => resolvePhysicalPath(n)) + + // 先删物理文件(失败时 DB 记录仍在,可重试) + const deleteResults = await Promise.allSettled( + filesToDelete.map(p => fs.unlink(p)) + ) + const failed = deleteResults.filter(r => r.status === 'rejected' && r.reason?.code !== 'ENOENT') + if (failed.length > 0) { + logger.warn(`${failed.length} files failed to delete, proceeding with DB cleanup`) + } + } + + // 删节点(CASCADE 自动删除子节点 + file_ref) + await db.delete(nodeTable).where(eq(nodeTable.id, nodeId)) +} +``` + +> **顺序说明**:先删物理文件、再删 DB 记录。如果物理文件删除部分失败,DB 记录仍在可重试;如果反过来先删 DB,则丢失了文件路径信息无法追溯。已不存在的文件(`ENOENT`)不视为错误。 + +#### 回收站展示 + +回收站只展示 Trash 的**直接子节点**(`parentId = SYSTEM_TRASH_ID`),每个条目是一个独立的删除操作单元。 + +#### 边界情况 + +| 场景 | 行为 | +|------|------| +| 删文件 A,再删其父目录 | Trash 中有两个独立条目:文件 A 和目录 | +| 恢复文件 A,但父目录在 Trash 中 | 提示"原目录已删除,请先恢复目录"或恢复到根目录 | +| 恢复目录 | 目录及其所有子节点恢复到原位(子节点 parentId 未变,自然跟随) | +| Trash 中目录包含子文件 | 子文件跟随目录,不单独展示为 Trash 条目 | +| 永久删除目录 | CASCADE 删除所有后代 + 清理物理文件 | +| **external: 用户在外部删除 .trash 中文件** | 下次永久删除时 `ENOENT` 静默忽略,DB 正常清理 | +| **external: 恢复时目标路径已被占用** | 拒绝恢复,提示用户先处理冲突(重命名或删除占位文件) | +| **external: chokidar 检测到 .trash 变动** | chokidar 配置 `ignored: ['**/.trash/**']`,不触发同步 | + +### 7.4 内容编辑 + +- 直接修改节点对应的真实文件。 +- 更新节点 `updated_at` 与元信息。 + +### 7.5 元信息编辑 + +- 更新节点字段(如 `name`、`ext`)。 +- `local_external` 模式需要同步更新真实文件名(文件系统 rename)。 + - 重命名前必须检查目标路径是否已存在同名文件/目录,若冲突则拒绝并返回错误。 +- `local_managed` 模式仅更新 DB(物理文件名为 UUID,不受影响)。 + +### 7.6 移动节点 + +- **同 mount 内移动**:更新 `parentId`。`local_external` 模式需同步物理文件 rename。 +- **跨 mount 移动**:**禁止**。不同 mount 的存储模式(managed vs external vs remote)不兼容,跨 mount 移动需要物理文件格式转换和全子树 `mountId` 递归更新,复杂度高且易出错。用户如需跨 mount,应使用"复制到目标 mount → 删除源文件"的显式流程。 + +```typescript +async function moveNode(nodeId: string, targetParentId: string): Promise { + const node = await getNode(nodeId) + const targetParent = await getNode(targetParentId) + + // 禁止移动到自身 + if (nodeId === targetParentId) { + throw new Error('Cannot move a node into itself.') + } + + // 禁止跨 mount 移动 + if (node.mountId !== targetParent.mountId) { + throw new Error('Cross-mount move is not supported. Use copy + delete instead.') + } + + // 禁止移动到自身后代(防止形成环) + if (node.type === 'dir') { + const ancestors = await getAncestors(targetParentId) // 递归 CTE 查祖先链 + if (ancestors.some(a => a.id === nodeId)) { + throw new Error('Cannot move a directory into its own descendant.') + } + } + + // local_external 模式:同步物理文件移动 + if (getProviderType(node.mountId) === 'local_external') { + const oldPath = resolvePhysicalPath(node) + const newPath = resolvePhysicalPath({ ...node, parentId: targetParentId }) + // 检查目标路径是否已存在同名文件/目录 + if (await fs.pathExists(newPath)) { + throw new Error(`Target path already exists: ${newPath}`) + } + await fs.rename(oldPath, newPath) + } + + await db.update(nodeTable) + .set({ parentId: targetParentId }) + .where(eq(nodeTable.id, nodeId)) + + return getNode(nodeId) +} +``` + +### 7.7 元数据统一入口(问题 13) + +- `FileMetadata` 等元数据生成收口到统一工厂入口。 +- 保证 `ext`/`type` 生成策略一致,避免多入口规则漂移。 + +--- + +## 八、引用清理机制 + +### 8.1 问题 + +`file_ref.sourceId` 是多态字段(可能是 message ID、knowledge item ID、painting ID 等),无法用数据库外键约束级联删除。当业务对象被删除时,对应的 `file_ref` 记录可能变成悬挂引用。 + +### 8.2 三层防护 + +``` +┌─────────────────────────────────────────────┐ +│ 第一层:nodeId CASCADE │ +│ 文件节点删除 → file_ref 自动级联删除 │ +│ (由 DB FK 约束保证,无需应用层代码) │ +├─────────────────────────────────────────────┤ +│ 第二层:业务删除钩子 │ +│ 业务对象删除时,主动清理对应 file_ref │ +│ (应用层 Service 代码,各删除路径必须接入) │ +├─────────────────────────────────────────────┤ +│ 第三层:定期孤儿扫描 │ +│ 后台任务扫描 sourceId 不存在的 file_ref │ +│ (兜底安全网,补偿遗漏的清理路径) │ +└─────────────────────────────────────────────┘ +``` + +### 8.3 第一层:nodeId CASCADE + +`fileRefTable.nodeId` 外键 `onDelete: 'cascade'` 已在 Schema 中定义。 + +- 文件节点被永久删除(如清空回收站)→ 其所有 `file_ref` 自动删除 +- 无需任何应用层代码 + +### 8.4 第二层:业务删除钩子 + +统一入口 `FileRefService`: + +```typescript +class FileRefService { + /** 清理某个业务对象的所有文件引用 */ + async cleanupBySource(sourceType: string, sourceId: string): Promise { + await db.delete(fileRefTable).where( + and( + eq(fileRefTable.sourceType, sourceType), + eq(fileRefTable.sourceId, sourceId), + ) + ) + } + + /** 批量清理(如删除 topic 时一次性清理所有消息的引用) */ + async cleanupBySourceBatch(sourceType: string, sourceIds: string[]): Promise { + await db.delete(fileRefTable).where( + and( + eq(fileRefTable.sourceType, sourceType), + inArray(fileRefTable.sourceId, sourceIds), + ) + ) + } +} +``` + +各业务删除路径的接入点: + +| 删除场景 | 触发位置 | 清理调用 | +|---------|---------|---------| +| 删除消息 | `MessageService.delete()` | `cleanupBySource('chat_message', messageId)` | +| 删除 topic | `TopicService.delete()` | 先查出所有 messageIds → `cleanupBySourceBatch('chat_message', messageIds)` | +| 删除知识库 | `KnowledgeService.delete()` | `cleanupBySourceBatch('knowledge_item', itemIds)` | +| 删除知识库条目 | `KnowledgeService.remove()` | `cleanupBySource('knowledge_item', itemId)` | +| 删除 painting | painting 删除逻辑 | `cleanupBySource('painting', paintingId)` | + +### 8.5 第三层:注册式孤儿扫描器 + +为避免 hardcode 各业务表的 JOIN,采用**注册式 checker 模式**——各模块注册自己的存在性检查函数,扫描器泛型执行。 + +```typescript +/** 存在性检查接口 */ +interface SourceTypeChecker { + sourceType: string + /** 给一批 sourceId,返回其中仍然存在的 ID 集合 */ + checkExists: (sourceIds: string[]) => Promise> +} + +class OrphanRefScanner { + private checkers: Map = new Map() + private BATCH_SIZE = 200 + + register(checker: SourceTypeChecker): void { + this.checkers.set(checker.sourceType, checker) + } + + /** 扫描一种 sourceType 的孤儿引用,分批处理(cursor-based 分页) */ + async scanOneType(sourceType: string): Promise { + const checker = this.checkers.get(sourceType) + if (!checker) return 0 + + let cleaned = 0 + let lastSeenId = '' + + while (true) { + // 使用 cursor-based 分页,避免删除记录后 offset 跳过数据 + const refs = await db.select({ id: fileRefTable.id, sourceId: fileRefTable.sourceId }) + .from(fileRefTable) + .where(and( + eq(fileRefTable.sourceType, sourceType), + gt(fileRefTable.id, lastSeenId), + )) + .orderBy(asc(fileRefTable.id)) + .limit(this.BATCH_SIZE) + + if (refs.length === 0) break + + lastSeenId = refs[refs.length - 1].id + + const sourceIds = refs.map(r => r.sourceId) + const existingIds = await checker.checkExists(sourceIds) + const orphanRefIds = refs + .filter(r => !existingIds.has(r.sourceId)) + .map(r => r.id) + + if (orphanRefIds.length > 0) { + await db.delete(fileRefTable) + .where(inArray(fileRefTable.id, orphanRefIds)) + cleaned += orphanRefIds.length + } + } + return cleaned + } + + /** 扫描所有已注册的 sourceType */ + async scanAll(): Promise<{ total: number; byType: Record }> { + const byType: Record = {} + let total = 0 + for (const [sourceType] of this.checkers) { + const cleaned = await this.scanOneType(sourceType) + byType[sourceType] = cleaned + total += cleaned + } + return { total, byType } + } +} +``` + +各模块注册示例: + +```typescript +orphanScanner.register({ + sourceType: 'chat_message', + checkExists: async (ids) => { + const rows = await db.select({ id: messageTable.id }) + .from(messageTable) + .where(inArray(messageTable.id, ids)) + return new Set(rows.map(r => r.id)) + } +}) +``` + +触发时机: + +- 应用启动后延迟 30 秒执行(低优先级后台任务) +- 每次扫描一种 sourceType,间隔 5 秒(避免阻塞主进程) +- 用户可在设置页面手动触发"清理无效引用" + +### 8.6 无引用文件的处理 + +当一个文件的所有引用都被清理后,文件变成"无人引用"状态。 + +策略:**文件保留,用户手动管理**。 + +- 无引用不代表用户不需要该文件(可能备用或手动浏览) +- 文件页面可以显示"未引用"标记,方便用户批量清理 +- 不自动移入 Trash,避免用户困惑 + +--- + +## 九、API 层设计 + +遵循项目现有的 DataApi 架构模式:Schema(shared)→ Service(main)→ Handler(main)→ Hooks(renderer)。 + +### 9.1 API Schema 定义 + +位于 `packages/shared/data/api/schemas/files.ts`,与 `TopicSchemas`、`MessageSchemas` 同级。 + +```typescript +export type FileSchemas = { + // ─── 节点 CRUD ─── + + '/files/nodes': { + /** 查询节点列表(支持按 mountId/parentId/inTrash 过滤) */ + GET: { + query: { + mountId?: string + parentId?: string + type?: 'file' | 'dir' + inTrash?: boolean + } + response: FileNode[] + } + /** 创建节点(上传文件 / 创建目录) */ + POST: { + body: CreateNodeDto + response: FileNode + } + } + + '/files/nodes/:id': { + GET: { params: { id: string }; response: FileNode } + /** 更新节点元信息(重命名等) */ + PATCH: { params: { id: string }; body: UpdateNodeDto; response: FileNode } + /** 永久删除节点 */ + DELETE: { params: { id: string }; response: void } + } + + // ─── 树操作 ─── + + '/files/nodes/:id/children': { + /** 获取子节点(文件树懒加载,支持排序和分页) */ + GET: { + params: { id: string } + query: { + recursive?: boolean + sortBy?: 'name' | 'updatedAt' | 'size' | 'type' + sortOrder?: 'asc' | 'desc' + limit?: number + offset?: number + } + response: FileNode[] + } + } + + '/files/nodes/:id/move': { + /** 移动节点到新父节点 */ + PUT: { params: { id: string }; body: { targetParentId: string }; response: FileNode } + } + + '/files/nodes/:id/trash': { + /** 移入 Trash(软删除) */ + PUT: { params: { id: string }; response: void } + } + + '/files/nodes/:id/restore': { + /** 从 Trash 恢复 */ + PUT: { params: { id: string }; response: FileNode } + } + + // ─── 文件引用 ─── + + '/files/nodes/:id/refs': { + /** 查询文件的所有引用方 */ + GET: { params: { id: string }; response: FileRef[] } + /** 创建引用 */ + POST: { params: { id: string }; body: CreateFileRefDto; response: FileRef } + } + + '/files/refs/by-source': { + /** 查询某个业务对象引用的所有文件 */ + GET: { query: { sourceType: string; sourceId: string }; response: FileRef[] } + /** 清理某个业务对象的所有引用 */ + DELETE: { query: { sourceType: string; sourceId: string }; response: void } + } + + // ─── 批量操作 ─── + + '/files/nodes/batch/trash': { + /** 批量移入 Trash */ + PUT: { body: { ids: string[] }; response: void } + } + + '/files/nodes/batch/move': { + /** 批量移动到目标目录 */ + PUT: { body: { ids: string[]; targetParentId: string }; response: void } + } + + '/files/nodes/batch/delete': { + /** 批量永久删除 */ + DELETE: { body: { ids: string[] }; response: void } + } + + // ─── 挂载点 ─── + + '/files/mounts': { + /** 获取挂载点列表 */ + GET: { + query: { includeSystem?: boolean } // 默认 false,不返回 Trash 等系统挂载点 + response: FileNode[] + } + } +} +``` + +### 9.2 Service 层 + +位于 `src/main/data/services/`,遵循现有 `TopicService`、`MessageService` 模式。 + +```typescript +// FileNodeService - 节点业务逻辑 +class FileNodeService { + async create(dto: CreateNodeDto): Promise + async getById(id: string): Promise + async update(id: string, dto: UpdateNodeDto): Promise + async permanentDelete(id: string): Promise // 硬删 + 物理文件清理 + async list(filters: NodeListFilters): Promise + async getChildren(parentId: string, options?: { + recursive?: boolean + sortBy?: 'name' | 'updatedAt' | 'size' | 'type' + sortOrder?: 'asc' | 'desc' + limit?: number + offset?: number + }): Promise + /** 获取挂载点列表。includeSystem=false 时排除系统挂载点(如 Trash) */ + async getMounts(includeSystem?: boolean): Promise + async move(id: string, targetParentId: string): Promise + async moveBatch(ids: string[], targetParentId: string): Promise + async trash(id: string): Promise + async trashBatch(ids: string[]): Promise + async restore(id: string): Promise + async permanentDeleteBatch(ids: string[]): Promise + async resolvePhysicalPath(id: string): Promise +} + +// FileRefService - 引用关系管理(同第八章定义) +class FileRefService { + async create(nodeId: string, dto: CreateFileRefDto): Promise + async getByNode(nodeId: string): Promise + async getBySource(sourceType: string, sourceId: string): Promise + async cleanupBySource(sourceType: string, sourceId: string): Promise + async cleanupBySourceBatch(sourceType: string, sourceIds: string[]): Promise +} +``` + +### 9.3 Handler 注册 + +位于 `src/main/data/api/handlers/files.ts`,合并到 `apiHandlers`。 + +```typescript +export const fileHandlers = { + '/files/nodes': { + GET: async ({ query }) => nodeService.list(query), + POST: async ({ body }) => nodeService.create(body), + }, + '/files/nodes/:id': { + GET: async ({ params }) => nodeService.getById(params.id), + PATCH: async ({ params, body }) => nodeService.update(params.id, body), + DELETE: async ({ params }) => { await nodeService.permanentDelete(params.id) }, + }, + '/files/nodes/:id/children': { + GET: async ({ params, query }) => nodeService.getChildren(params.id, { + recursive: query?.recursive, + sortBy: query?.sortBy, + sortOrder: query?.sortOrder, + limit: query?.limit, + offset: query?.offset, + }), + }, + '/files/nodes/:id/move': { + PUT: async ({ params, body }) => nodeService.move(params.id, body.targetParentId), + }, + '/files/nodes/:id/trash': { + PUT: async ({ params }) => { await nodeService.trash(params.id) }, + }, + '/files/nodes/:id/restore': { + PUT: async ({ params }) => nodeService.restore(params.id), + }, + '/files/nodes/:id/refs': { + GET: async ({ params }) => fileRefService.getByNode(params.id), + POST: async ({ params, body }) => fileRefService.create(params.id, body), + }, + '/files/refs/by-source': { + GET: async ({ query }) => fileRefService.getBySource(query.sourceType, query.sourceId), + DELETE: async ({ query }) => { await fileRefService.cleanupBySource(query.sourceType, query.sourceId) }, + }, + '/files/nodes/batch/trash': { + PUT: async ({ body }) => { await nodeService.trashBatch(body.ids) }, + }, + '/files/nodes/batch/move': { + PUT: async ({ body }) => { await nodeService.moveBatch(body.ids, body.targetParentId) }, + }, + '/files/nodes/batch/delete': { + DELETE: async ({ body }) => { await nodeService.permanentDeleteBatch(body.ids) }, + }, + '/files/mounts': { + GET: async ({ query }) => nodeService.getMounts(query?.includeSystem), + }, +} + +// handlers/index.ts 中合并 +export const apiHandlers: ApiImplementation = { + ...topicHandlers, + ...messageHandlers, + ...fileHandlers, // ← 新增 +} +``` + +### 9.4 Renderer 使用示例 + +```typescript +// 获取某目录下的子节点(文件树懒加载) +const { data: children } = useQuery('/files/nodes/:id/children', { + params: { id: folderId }, +}) + +// 获取 Trash 内容 +const { data: trashItems } = useQuery('/files/nodes', { + query: { inTrash: true }, +}) + +// 创建文件节点 +const { trigger: createNode } = useMutation('POST', '/files/nodes', { + refresh: ['/files/nodes'], +}) +await createNode({ body: { type: 'file', name: 'report', ext: 'pdf', parentId, mountId } }) + +// 移入 Trash +const { trigger: trashNode } = useMutation('PUT', '/files/nodes/:id/trash', { + refresh: ['/files/nodes'], +}) +await trashNode({ params: { id: nodeId } }) + +// 恢复 +const { trigger: restore } = useMutation('PUT', '/files/nodes/:id/restore', { + refresh: ['/files/nodes'], +}) + +// 查询文件被谁引用 +const { data: refs } = useQuery('/files/nodes/:id/refs', { + params: { id: fileNodeId }, +}) + +// 查询某消息引用的所有文件 +const { data: messageFiles } = useQuery('/files/refs/by-source', { + query: { sourceType: 'chat_message', sourceId: messageId }, +}) +``` + +### 9.5 文件上传流程(特殊处理) + +文件上传不同于普通 CRUD,需要先传输物理文件再创建节点。保留现有的 IPC 通道用于文件传输: + +``` +1. Renderer: window.api.file.upload(file) ← 复用现有 IPC(物理文件传输) +2. Main: FileStorage 写入物理文件 ← 复用现有逻辑 +3. Main: FileNodeService.create(nodeDto) ← 新增:写入节点表 +4. Main: 返回 FileNode ← 替代原有 FileMetadata +``` + +上传是原子操作:物理文件写入 + 节点创建在同一个 main 进程事务中完成,解决了 P1/P2 问题。 + +--- + +## 十、迁移策略 + +### 10.1 FileMigrator 设计 + +```typescript +class FileMigrator extends BaseMigrator { + readonly id = 'file' + readonly name = 'File Migration' + readonly description = 'Migrate files from Dexie to node table' + readonly order = 2.5 // After Assistant(2), Before Knowledge(3) +} +``` + +**执行顺序**: + +``` +Preferences(1) → Assistant(2) → File(2.5) → Knowledge(3) → Chat(4) + ↑ 新增 +``` + +- FileMigrator 在 Knowledge 和 Chat 之前运行,确保文件节点已就绪 +- 后续迁移器(Knowledge、Chat)可以创建各自的 file_ref 记录 +- PaintingMigrator 不在本次范围内,随 Painting 业务重构独立推进 + +### 10.2 三阶段迁移流程 + +**Prepare 阶段**: + +```typescript +async prepare(ctx: MigrationContext): Promise { + // 1. 检查 Dexie files 表是否存在 + const hasFiles = await ctx.sources.dexieExport.tableExists('files') + if (!hasFiles) return { success: true, itemCount: 0 } + + // 2. 读取并计数 + const reader = ctx.sources.dexieExport.createStreamReader('files') + const count = await reader.count() + + // 3. 样本验证(检查必须字段) + const sample = await reader.readSample(10) + const warnings: string[] = [] + for (const file of sample) { + if (!file.id || !file.origin_name) { + warnings.push(`File ${file.id} missing required fields`) + } + } + + return { success: true, itemCount: count, warnings } +} +``` + +**Execute 阶段**: + +```typescript +async execute(ctx: MigrationContext): Promise { + const BATCH_SIZE = 100 + + // 1. 创建系统节点(幂等) + await this.ensureSystemNodes(ctx.db) + + // 2. 流式读取旧文件数据 + const reader = ctx.sources.dexieExport.createStreamReader('files') + const totalCount = await reader.count() + let processed = 0 + + // 3. 文件 ID 映射(供后续迁移器使用) + const fileIdMap = new Map() // oldId → newNodeId + + await reader.readInBatches(BATCH_SIZE, async (batch) => { + const nodes = batch.map(oldFile => this.transformFile(oldFile)) + + await ctx.db.insert(nodeTable).values(nodes) + + // 记录 ID 映射(旧系统和新系统使用相同 ID,因此映射是 1:1) + for (const node of nodes) { + fileIdMap.set(node.id, node.id) + } + + processed += batch.length + this.reportProgress( + Math.round((processed / totalCount) * 100), + `Migrated ${processed}/${totalCount} files`, + { key: 'migration.progress.files', params: { current: processed, total: totalCount } } + ) + }) + + // 4. 存储映射到 sharedData,供 Knowledge/Chat 迁移器使用 + ctx.sharedData.set('fileIdMap', fileIdMap) + ctx.sharedData.set('fileMountId', 'mount_files') + + return { success: true, processedCount: processed } +} +``` + +**字段转换规则**: + +```typescript +private transformFile(old: DexieFileMetadata): NewNodeInsert { + return { + id: old.id, // 保持原 ID 不变 + type: 'file', + name: old.origin_name || old.name, // 优先 origin_name(用户可见名) + ext: old.ext?.replace(/^\./, '') || null, // 去除前导点 + parentId: 'mount_files', // 全部归入 Files 挂载点根目录 + mountId: 'mount_files', + size: old.size || null, + createdAt: parseTimestamp(old.created_at), // ISO 8601 → ms timestamp + updatedAt: parseTimestamp(old.created_at), + } +} + +function parseTimestamp(iso?: string): number { + return iso ? new Date(iso).getTime() : Date.now() +} +``` + +**设计说明**: + +- **ID 保持不变**:旧 `FileMetadata.id` 直接作为新 `nodeTable.id`,这样所有引用该 ID 的地方(message blocks 的 `fileId`、knowledge items 的文件引用)无需修改 +- **全部归入 Files 根目录**:旧系统无目录概念,迁移后全部作为 `mount_files` 的直接子节点。用户可以后续手动整理 +- **物理文件无需移动**:旧存储路径 `{userData}/Data/Files/{id}{ext}` 与新 managed 模式路径 `{mount.base_path}/{id}.{ext}` 一致(仅 ext 前可能差一个点,需在路径解析器中兼容) + +**Validate 阶段**: + +```typescript +async validate(ctx: MigrationContext): Promise { + const reader = ctx.sources.dexieExport.createStreamReader('files') + const sourceCount = await reader.count() + + const [{ count: targetCount }] = await ctx.db + .select({ count: sql`count(*)` }) + .from(nodeTable) + .where(and( + eq(nodeTable.type, 'file'), + eq(nodeTable.mountId, 'mount_files') + )) + + const errors: ValidationError[] = [] + if (sourceCount !== targetCount) { + errors.push({ + key: 'file_count_mismatch', + expected: sourceCount, + actual: targetCount, + message: `Expected ${sourceCount} files, found ${targetCount}`, + }) + } + + return { + success: errors.length === 0, + errors, + stats: { sourceCount, targetCount, skippedCount: sourceCount - targetCount }, + } +} +``` + +### 10.3 其他迁移器的 file_ref 创建 + +**KnowledgeMigrator(order=3)**: + +迁移知识库条目时,对于 `type: 'file'` 或 `type: 'video'` 的 knowledge item,从其 `content` 中提取 `FileMetadata.id`,创建 file_ref 记录: + +```typescript +// KnowledgeMigrator.execute() 中 +const fileIdMap = ctx.sharedData.get('fileIdMap') as Map + +if (item.type === 'file' && item.content?.id) { + if (fileIdMap.has(item.content.id)) { + await ctx.db.insert(fileRefTable).values({ + id: generateUUID(), + nodeId: item.content.id, // FileMetadata.id = nodeTable.id + sourceType: 'knowledge_item', + sourceId: newKnowledgeItemId, + role: 'source', + }) + } else { + logger.warn(`Skipping file_ref: node ${item.content.id} not found in nodeTable`) + } +} +``` + +**ChatMigrator(order=4)**: + +迁移消息 blocks 时,对于含 `fileId` 的 block,创建 file_ref 记录。 + +> **容错要求**:旧数据中可能存在文件已被删除但消息/知识库条目未清理的情况,此时 `block.fileId` 指向的节点不存在于 `nodeTable` 中。由于 `fileRefTable.nodeId` 有 FK 约束,直接插入会失败。因此必须**先验证节点存在性**,不存在的跳过并记录 warning。 + +```typescript +// ChatMigrator 的 block 转换中 +const fileIdMap = ctx.sharedData.get('fileIdMap') as Map + +if ((block.type === 'file' || block.type === 'image') && block.fileId) { + // 验证文件节点存在(已迁移)后才创建引用 + if (fileIdMap.has(block.fileId)) { + fileRefsToInsert.push({ + id: generateUUID(), + nodeId: block.fileId, + sourceType: 'chat_message', + sourceId: messageId, + role: 'attachment', + }) + } else { + logger.warn(`Skipping file_ref: node ${block.fileId} not found in nodeTable`) + } +} +``` + +### 10.4 Paintings 迁移(不在本次范围内) + +Paintings 数据存储在 Redux state 中(`PaintingParams.files: FileMetadata[]`)。 + +**决策**:PaintingMigrator 不在本次文件管理重构范围内,随 Painting 业务重构独立推进。 + +- 唯一依赖:FileMigrator 已将文件节点写入 nodeTable(保持原 ID),PaintingMigrator 可直接用 `FileMetadata.id` 作为 `nodeId` 创建 file_ref +- 在 PaintingMigrator 实现之前,painting 引用的文件不会有 file_ref 记录,但文件节点本身已存在且可访问 +- `sourceType: 'painting'` 已纳入 OrphanRefScanner 的注册式设计,PaintingMigrator 上线后自动覆盖 + +### 10.5 迁移回滚策略 + +| 场景 | 回滚方案 | +|------|---------| +| FileMigrator 执行失败 | MigrationEngine 标记失败,用户可重试。nodeTable 清空重来(TRUNCATE + 重跑) | +| 迁移完成后发现数据异常 | Dexie 导出文件(`files.json`)保留不删除,可重建 nodeTable | +| 新旧系统并行期数据冲突 | `toFileMetadata` 适配层保证旧消费方仍可工作 | +| 物理文件丢失 | 迁移不移动物理文件,路径不变,无文件丢失风险 | + +--- + +## 十一、分阶段实施计划 + +### 11.1 总览 + +整个文件管理重构分为 **6 个阶段**,作为 v2 迁移的一部分推进。各阶段有明确的交付物和依赖关系。 + +``` +Phase 1 Phase 2 Phase 3 Phase 4 Phase 5 Phase 6 +Schema & ──→ Core Services ──→ FileMigrator ──→ Consumer ──→ Notes ──→ Cleanup +Foundation + API + 迁移整合 Migration Integration + (分 4 批) +``` + +### 11.2 Phase 1: Schema & Foundation + +**目标**:建立所有类型定义和数据库 Schema,为后续阶段提供基础。 + +**交付物**: + +| 文件路径 | 内容 | +|---------|------| +| `src/main/data/db/schemas/node.ts` | `nodeTable` + `fileRefTable` Drizzle Schema(第六章) | +| `packages/shared/data/types/fileNode.ts` | `FileNode`、`FileRef`、DTO 类型定义(第六章) | +| `packages/shared/data/types/fileProvider.ts` | Provider Config Zod Schema(第五章) | +| `packages/shared/data/api/schemas/files.ts` | `FileSchemas` API 类型声明(第九章) | + +**关键任务**: + +1. 创建 Drizzle Schema 并生成 migration SQL(`pnpm db:migrations:generate`) +2. 实现系统节点初始化逻辑——首次启动时创建 `mount_files`、`mount_notes`、`system_trash` +3. 实现路径解析器 `resolvePhysicalPath(node)`:根据 `providerConfig.provider_type` 计算物理路径 + - `local_managed`:`{mount.base_path}/{node.id}.{node.ext}` + - `local_external`:`{mount.base_path}/{...ancestors}/{node.name}.{node.ext}` + +**依赖**:无 + +### 11.3 Phase 2: Core Services + API + +**目标**:实现完整的节点 CRUD 和引用管理能力,可以独立于旧系统运行。 + +**交付物**: + +| 文件路径 | 内容 | +|---------|------| +| `src/main/data/services/FileNodeService.ts` | 节点 CRUD、树操作、Trash、路径解析 | +| `src/main/data/services/FileRefService.ts` | 引用 CRUD、按来源清理、批量清理 | +| `src/main/data/api/handlers/files.ts` | DataApi handlers(第九章) | +| `src/renderer/src/hooks/useFileNodes.ts` | SWR hooks(`useQuery`/`useMutation` 封装) | + +**关键任务**: + +1. `FileNodeService`: + - `create()` 原子性保障——物理文件写入 + 节点记录在同一事务 + - `trash()` / `restore()` 实现 OS 风格 Trash 逻辑(第七章) + - `permanentDelete()` 级联删除 + 物理文件清理 + - `move()` 物理文件移动(external 模式)或纯 DB 更新(managed 模式) + - 内存级路径缓存 `Map`,树变更时重建 + +2. `FileRefService`: + - CRUD + 按 source 查询/清理 + - `OrphanRefScanner` 注册式扫描器(第八章) + +3. Handler 注册到 `apiHandlers`,URL 前缀 `/files/` + +4. Renderer hooks 封装,支持文件树懒加载 + +**依赖**:Phase 1 + +### 11.4 Phase 3: FileMigrator + 迁移整合 + +**目标**:将旧 Dexie `db.files` 数据迁移到新 nodeTable,并协调其他迁移器创建 file_ref 记录。 + +详见第十章。 + +**依赖**:Phase 1, Phase 2 + +### 11.5 Phase 4: Consumer Migration(消费方迁移) + +**目标**:将 64+ 个引用 `FileMetadata` 的文件逐步迁移到使用 `FileNode` + DataApi。 + +分 4 批进行,按依赖关系和影响范围排序: + +#### Batch 4.1: 数据层适配(影响最小,验证新系统可用性) + +| 文件 | 变更 | +|------|------| +| `src/renderer/src/services/FileManager.ts` | 重写为 thin wrapper,调用 DataApi hooks 而非直接操作 Dexie | +| `src/main/services/FileStorage.ts` | 文件 I/O 保留,元数据管理迁移到 `FileNodeService` | +| `src/renderer/src/types/file.ts` | `FileMetadata` 标记 `@deprecated`,新增 re-export from `FileNode` | + +**兼容策略**:提供 `toFileMetadata(node: FileNode): FileMetadata` 适配函数,让尚未迁移的消费方继续工作: + +```typescript +/** @deprecated 仅用于过渡期 */ +function toFileMetadata(node: FileNode): FileMetadata { + return { + id: node.id, + name: node.id + (node.ext ? '.' + node.ext : ''), + origin_name: node.name + (node.ext ? '.' + node.ext : ''), + path: resolvePhysicalPath(node), + size: node.size ?? 0, + ext: node.ext ? '.' + node.ext : '', + type: inferFileType(node.ext), + created_at: new Date(node.createdAt).toISOString(), + count: 0, // deprecated, 引用计数由 file_ref 聚合 + } +} +``` + +#### Batch 4.2: AI Core(影响核心功能,需充分测试) + +| 文件 | 变更 | +|------|------| +| `fileProcessor.ts` | 入参从 `FileMetadata` 改为 `FileNode`,路径通过 `resolvePhysicalPath` 获取 | +| `messageConverter.ts` | 从 file blocks 中读取 `fileId` → 查询 `FileNode` → 获取路径/元数据 | +| 各 API 客户端 | 文件上传参数适配 | + +#### Batch 4.3: Knowledge + Paintings + +| 文件 | 变更 | +|------|------| +| `KnowledgeService.ts` | 文件引用从嵌入 `FileMetadata` 改为 `nodeId` 引用 | +| 6+ 预处理 providers | 入参改为 `FileNode` 或 `{ path, ext, name }` | +| Painting 相关 | `files: FileMetadata[]` → `fileIds: string[]` | + +#### Batch 4.4: UI + State Management + +| 文件 | 变更 | +|------|------| +| 文件页面组件 | 从 `db.files` 查询改为 `useQuery('/files/nodes')` | +| 消息 block 组件 | 文件展示从嵌入数据改为通过 `fileId` 查询 | +| 绘图页面 | 适配 `fileIds` 引用模式 | +| `messageThunk.ts` | 文件上传流程走新 API | +| `knowledgeThunk.ts` | 文件引用走新 API | + +**每个 Batch 完成后**:运行 `pnpm build:check`(lint + test + typecheck),确保不引入回归。 + +**依赖**:Phase 2, Phase 3 + +### 11.6 Phase 5: Notes Integration + +**目标**:将笔记文件树纳入节点表管理,保持外部编辑器兼容。 + +**交付物**: + +| 文件路径 | 内容 | +|---------|------| +| `src/main/services/ExternalSyncEngine.ts` | 文件系统 ↔ DB 同步引擎 | +| `src/main/services/FileWatcherService.ts` | 基于 chokidar 的文件监听(复用现有逻辑) | + +**关键任务**: + +1. **启动同步**:扫描 `notesPath` → diff 与 `mount_notes` 下的节点表 → 增删改对齐(排除 `.trash` 目录) +2. **运行时同步**:chokidar 事件 → 防抖(200ms)→ 增量更新节点表(`ignored: ['**/.trash/**']`) +3. **冲突策略**:文件系统 wins(见第四章 4.4 节) +4. **迁移**:首次启动时执行全量 reconcile,将现有笔记文件扫描入库 +5. **页面重构**:`NotesPage.tsx` 从直接调用 `getDirectoryStructure` 改为查询 `nodeTable` +6. **兼容保障**:外部编辑器创建/修改/删除文件时,应用内同步更新 + +**依赖**:Phase 2(与 Phase 3/4 可并行) + +### 11.7 Phase 6: Cleanup + +**目标**:移除所有旧代码路径。 + +**关键任务**: + +1. 移除 Dexie `files` 表定义和相关迁移代码 +2. 移除 `FileMetadata` 类型和 `toFileMetadata` 适配函数 +3. 移除 `FileManager.ts`(renderer 侧旧文件管理) +4. 清理 `FileStorage.ts` 中已迁移到 `FileNodeService` 的逻辑 +5. 移除 `findDuplicateFile()` 等去重相关代码 +6. 最终集成测试 + +**依赖**:Phase 4, Phase 5 + +### 11.8 阶段依赖关系图 + +``` +Phase 1 ──────────────┐ +(Schema) │ + ▼ +Phase 2 ──────────────┐ +(Services + API) │ + │ ▼ + │ Phase 3 + │ (FileMigrator) + │ │ + ├────────────────┤ + │ ▼ + │ Phase 4 (Batch 4.1 → 4.2 → 4.3 → 4.4) + │ (Consumer Migration) + │ │ + ▼ │ +Phase 5 │ +(Notes Integration) │ + │ │ + └────────┬───────┘ + ▼ + Phase 6 + (Cleanup) +``` + +**注意**:Phase 2 和 Phase 5 之间没有强依赖——Notes Integration 只需要 Core Services,不需要等 Consumer Migration 完成。两条路线可以并行推进。 + +--- + +## 十二、取舍记录(Trade-off) + +放弃文件池/去重的主要原因与代价: + +- 优点:OS 目录结构与用户视角一致,导出/备份更直观。 +- 代价:磁盘占用增加,重复内容不再复用。 +- 影响:引用计数与 COW 的价值降低,逻辑显著简化。 + +--- + +## 十三、风险项 + +| 风险 | 影响 | 缓解措施 | +|------|------|---------| +| `FileMetadata` 引用面太广(274 处) | Consumer Migration 工作量大 | `toFileMetadata` 适配函数 + 分批迁移 | +| 旧文件 `ext` 含点/不含点不统一 | 路径解析错误 | 迁移时统一 normalize 为不含点格式(§4.3),`resolvePhysicalPath` 拼接时始终加点 | +| KnowledgeMigrator 尚未实现 | File ref 创建时机不确定 | FileMigrator 不依赖 Knowledge,仅通过 `sharedData` 提供数据 | +| Painting 的 file_ref 暂缺 | 文件页面无法追溯 painting 引用 | 文件节点已存在可访问,file_ref 随 Painting 重构补建 | +| Notes 双向同步复杂度 | 冲突、数据不一致 | 文件系统 wins + 启动时全量 reconcile 兜底 | + +--- + +## 十四、待补充内容 + +- [ ] 笔记 external 模式的详细同步方案(Phase 5 细化设计) +- [ ] PaintingMigrator(随 Painting 业务重构独立推进,仅依赖 FileMigrator 提供的 fileId) diff --git a/v2-refactor-temp/docs/file-manager/vscode-explorer-tree-overview.md b/v2-refactor-temp/docs/file-manager/vscode-explorer-tree-overview.md new file mode 100644 index 00000000000..01838bb8050 --- /dev/null +++ b/v2-refactor-temp/docs/file-manager/vscode-explorer-tree-overview.md @@ -0,0 +1,86 @@ +# VS Code 文件树实现说明 + +本文档为外部参考设计说明,概述资源管理器(Explorer)文件树的实现结构、关键模块与数据流转路径,不依赖具体代码文件或内部路径。 + +## 1. 入口与视图注册 + +资源管理器视图由视图容器注册,并根据工作区状态(空工作区、单文件夹、多文件夹)切换显示内容。 + +- 关键点: + - 视图注册模块负责在合适时机注册/注销“文件树视图”或“空状态视图”。 + - 视图容器负责创建并承载文件树视图实例。 + +## 2. 树组件与视图层 + +文件树主体在视图层创建与管理,负责 UI 生命周期、交互与状态恢复。 + +- 关键点: + - 视图层创建树组件并绑定数据源、渲染器、过滤器、排序器等。 + - 树组件支持异步加载、压缩目录、键盘导航与可访问性。 + - 视图层负责设置树输入(单根或多根)与恢复/保存视图状态。 + +## 3. 数据模型 + +文件树的节点模型定义了树节点结构与文件系统属性。 + +- 关键点: + - 根节点集合与工作区绑定,并在工作区变化时更新。 + - 单个节点表示文件或文件夹,包含名称、路径、父子关系、只读属性等。 + - 子节点按需解析,支持延迟加载。 + - 支持文件嵌套(file nesting)与节点合并更新。 + +## 4. 数据源 + +树控件通过数据源获取节点及其子节点。 + +- 关键点: + - 数据源负责桥接树控件与模型层的子节点解析。 + - 统一处理错误与进度展示,避免阻塞主线程体验。 + +## 5. 渲染 + +节点渲染、图标、压缩目录等逻辑在渲染器中处理。 + +- 关键点: + - 渲染器负责绘制标签、图标、装饰、计数徽标等。 + - 支持“紧凑文件夹”(compressed folders)显示与重命名输入框。 + +## 6. 过滤与隐藏规则 + +过滤逻辑决定哪些节点可见。 + +- 关键点: + - 支持用户配置的排除规则与版本控制忽略规则。 + - 当忽略文件发生变化时自动更新过滤结果。 + - 已打开编辑器对应的文件可被提升为可见。 + +## 7. 排序与拖拽 + +排序与拖拽由树组件的配置与扩展点提供。 + +- 排序支持按名称、类型、修改时间与自定义规则。 +- 拖拽支持内部移动、跨窗口/外部导入等场景,并配合权限与冲突提示。 + +## 8. 运行时更新与刷新 + +文件树通过刷新与文件系统事件保持与磁盘一致。 + +- 视图层可以触发局部或全量刷新。 +- 过滤层监听文件系统变化以更新忽略规则。 +- 专用的文件树服务负责协调文件系统事件与模型更新。 + +## 9. 树控件底层 + +Explorer 依赖通用树控件能力,提供高性能虚拟滚动、可访问性与异步加载支持。 + +## 10. 建议阅读顺序(概念层面) + +1. 视图注册与容器 +2. 视图层与树组件 +3. 数据模型 +4. 数据源与渲染 +5. 文件系统事件与刷新机制 + +--- + +如需更细的事件流或具体调用链(例如某个命令如何影响树刷新),可以指定场景,我可以补充详细追踪说明。