From 315143f7c0cfc9b6ed2bbc1ddd93dfc5811da138 Mon Sep 17 00:00:00 2001 From: MrSibe Date: Wed, 30 Sep 2026 15:38:59 +0800 Subject: [PATCH] feat(chat): give a notebook many named chat sessions (#97) --- .../db/migrations/0022_concerned_spyke.sql | 3 + .../db/migrations/meta/0022_snapshot.json | 1850 +++++++++++++++++ src/main/db/migrations/meta/_journal.json | 7 + src/main/db/queries.ts | 122 +- src/main/db/schema.ts | 12 +- src/main/ipc/chatHandlers.ts | 31 +- src/main/ipc/validation.ts | 12 + src/main/services/SessionAutoSwitchService.ts | 26 +- src/preload/index.d.ts | 15 +- src/preload/index.ts | 11 +- .../src/components/notebook/ProcessPanel.tsx | 31 +- .../chat/SessionContinuationNotice.tsx | 50 + .../notebook/chat/SessionSwitcher.tsx | 368 ++++ src/renderer/src/locales/en-US/chat.json | 14 +- src/renderer/src/locales/zh-CN/chat.json | 14 +- src/renderer/src/store/chatStore.ts | 124 +- src/shared/types/chat.ts | 16 + src/shared/utils/sessionTitle.ts | 48 + test/chatTurnEvents.test.ts | 5 + test/sessionTitle.test.ts | 54 + 20 files changed, 2755 insertions(+), 58 deletions(-) create mode 100644 src/main/db/migrations/0022_concerned_spyke.sql create mode 100644 src/main/db/migrations/meta/0022_snapshot.json create mode 100644 src/renderer/src/components/notebook/chat/SessionContinuationNotice.tsx create mode 100644 src/renderer/src/components/notebook/chat/SessionSwitcher.tsx create mode 100644 src/shared/utils/sessionTitle.ts create mode 100644 test/sessionTitle.test.ts diff --git a/src/main/db/migrations/0022_concerned_spyke.sql b/src/main/db/migrations/0022_concerned_spyke.sql new file mode 100644 index 0000000..b917940 --- /dev/null +++ b/src/main/db/migrations/0022_concerned_spyke.sql @@ -0,0 +1,3 @@ +ALTER TABLE `chat_sessions` ADD `title_is_auto` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `chat_sessions` ADD `last_opened_at` integer;--> statement-breakpoint +CREATE INDEX `idx_sessions_notebook_opened` ON `chat_sessions` (`notebook_id`,`last_opened_at`); \ No newline at end of file diff --git a/src/main/db/migrations/meta/0022_snapshot.json b/src/main/db/migrations/meta/0022_snapshot.json new file mode 100644 index 0000000..d633fdb --- /dev/null +++ b/src/main/db/migrations/meta/0022_snapshot.json @@ -0,0 +1,1850 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a22e0598-116b-43ee-9ff7-3a9cc08b8a11", + "prevId": "baab174f-764a-417c-8f5c-c1a56c3320fe", + "tables": { + "anki_cards": { + "name": "anki_cards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "notebook_id": { + "name": "notebook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cards_data": { + "name": "cards_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_mapping": { + "name": "chunk_mapping", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generating'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_ankicards_notebook": { + "name": "idx_ankicards_notebook", + "columns": [ + "notebook_id", + "updated_at" + ], + "isUnique": false + }, + "idx_ankicards_version": { + "name": "idx_ankicards_version", + "columns": [ + "notebook_id", + "version" + ], + "isUnique": false + } + }, + "foreignKeys": { + "anki_cards_notebook_id_notebooks_id_fk": { + "name": "anki_cards_notebook_id_notebooks_id_fk", + "tableFrom": "anki_cards", + "tableTo": "notebooks", + "columnsFrom": [ + "notebook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_content": { + "name": "reasoning_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finish_reason": { + "name": "finish_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt_of": { + "name": "attempt_of", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_messages_session": { + "name": "idx_messages_session", + "columns": [ + "session_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_session_id_chat_sessions_id_fk": { + "name": "chat_messages_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_sessions": { + "name": "chat_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "notebook_id": { + "name": "notebook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title_is_auto": { + "name": "title_is_auto", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retrieval_scope": { + "name": "retrieval_scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sessions_notebook": { + "name": "idx_sessions_notebook", + "columns": [ + "notebook_id", + "updated_at" + ], + "isUnique": false + }, + "idx_sessions_notebook_opened": { + "name": "idx_sessions_notebook_opened", + "columns": [ + "notebook_id", + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_sessions_notebook_id_notebooks_id_fk": { + "name": "chat_sessions_notebook_id_notebooks_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "notebooks", + "columnsFrom": [ + "notebook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_parent_session_id_chat_sessions_id_fk": { + "name": "chat_sessions_parent_session_id_chat_sessions_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "chat_sessions", + "columnsFrom": [ + "parent_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chunk_blocks": { + "name": "chunk_blocks", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_in_block": { + "name": "start_in_block", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_in_block": { + "name": "end_in_block", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_chunk_blocks_block": { + "name": "idx_chunk_blocks_block", + "columns": [ + "block_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chunk_blocks_chunk_id_chunks_id_fk": { + "name": "chunk_blocks_chunk_id_chunks_id_fk", + "tableFrom": "chunk_blocks", + "tableTo": "chunks", + "columnsFrom": [ + "chunk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chunk_blocks_block_id_document_blocks_id_fk": { + "name": "chunk_blocks_block_id_document_blocks_id_fk", + "tableFrom": "chunk_blocks", + "tableTo": "document_blocks", + "columnsFrom": [ + "block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "chunk_blocks_chunk_id_block_id_pk": { + "columns": [ + "chunk_id", + "block_id" + ], + "name": "chunk_blocks_chunk_id_block_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chunks": { + "name": "chunks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notebook_id": { + "name": "notebook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_start": { + "name": "page_start", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_end": { + "name": "page_end", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_chunks_document": { + "name": "idx_chunks_document", + "columns": [ + "document_id" + ], + "isUnique": false + }, + "idx_chunks_notebook": { + "name": "idx_chunks_notebook", + "columns": [ + "notebook_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chunks_document_id_documents_id_fk": { + "name": "chunks_document_id_documents_id_fk", + "tableFrom": "chunks", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chunks_notebook_id_notebooks_id_fk": { + "name": "chunks_notebook_id_notebooks_id_fk", + "tableFrom": "chunks", + "tableTo": "notebooks", + "columnsFrom": [ + "notebook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_blocks": { + "name": "document_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bbox": { + "name": "bbox", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_blocks_document_order": { + "name": "idx_blocks_document_order", + "columns": [ + "document_id", + "order" + ], + "isUnique": false + }, + "idx_blocks_document_page": { + "name": "idx_blocks_document_page", + "columns": [ + "document_id", + "page" + ], + "isUnique": false + } + }, + "foreignKeys": { + "document_blocks_document_id_documents_id_fk": { + "name": "document_blocks_document_id_documents_id_fk", + "tableFrom": "document_blocks", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "notebook_id": { + "name": "notebook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_uri": { + "name": "source_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_file_path": { + "name": "local_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_note_id": { + "name": "source_note_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "structure": { + "name": "structure", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "source_state": { + "name": "source_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'available'" + }, + "source_mtime_ms": { + "name": "source_mtime_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_documents_notebook": { + "name": "idx_documents_notebook", + "columns": [ + "notebook_id", + "updated_at" + ], + "isUnique": false + }, + "idx_documents_status": { + "name": "idx_documents_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "documents_notebook_id_notebooks_id_fk": { + "name": "documents_notebook_id_notebooks_id_fk", + "tableFrom": "documents", + "tableTo": "notebooks", + "columnsFrom": [ + "notebook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "documents_source_note_id_notes_id_fk": { + "name": "documents_source_note_id_notes_id_fk", + "tableFrom": "documents", + "tableTo": "notes", + "columnsFrom": [ + "source_note_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "embeddings": { + "name": "embeddings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "chunk_id": { + "name": "chunk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notebook_id": { + "name": "notebook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dimensions": { + "name": "dimensions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_embeddings_chunk": { + "name": "idx_embeddings_chunk", + "columns": [ + "chunk_id" + ], + "isUnique": false + }, + "idx_embeddings_notebook": { + "name": "idx_embeddings_notebook", + "columns": [ + "notebook_id" + ], + "isUnique": false + }, + "idx_embeddings_model": { + "name": "idx_embeddings_model", + "columns": [ + "model" + ], + "isUnique": false + } + }, + "foreignKeys": { + "embeddings_chunk_id_chunks_id_fk": { + "name": "embeddings_chunk_id_chunks_id_fk", + "tableFrom": "embeddings", + "tableTo": "chunks", + "columnsFrom": [ + "chunk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embeddings_notebook_id_notebooks_id_fk": { + "name": "embeddings_notebook_id_notebooks_id_fk", + "tableFrom": "embeddings", + "tableTo": "notebooks", + "columnsFrom": [ + "notebook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "folder_watches": { + "name": "folder_watches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "notebook_id": { + "name": "notebook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_folder_watches_notebook_path": { + "name": "idx_folder_watches_notebook_path", + "columns": [ + "notebook_id", + "path" + ], + "isUnique": true + } + }, + "foreignKeys": { + "folder_watches_notebook_id_notebooks_id_fk": { + "name": "folder_watches_notebook_id_notebooks_id_fk", + "tableFrom": "folder_watches", + "tableTo": "notebooks", + "columnsFrom": [ + "notebook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ingestion_runs": { + "name": "ingestion_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_ingestion_runs_document": { + "name": "idx_ingestion_runs_document", + "columns": [ + "document_id", + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ingestion_runs_document_id_documents_id_fk": { + "name": "ingestion_runs_document_id_documents_id_fk", + "tableFrom": "ingestion_runs", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "notebook_id": { + "name": "notebook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_items_notebook_order": { + "name": "idx_items_notebook_order", + "columns": [ + "notebook_id", + "order" + ], + "isUnique": false + }, + "idx_items_type": { + "name": "idx_items_type", + "columns": [ + "type" + ], + "isUnique": false + }, + "idx_items_resource": { + "name": "idx_items_resource", + "columns": [ + "type", + "resource_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "items_notebook_id_notebooks_id_fk": { + "name": "items_notebook_id_notebooks_id_fk", + "tableFrom": "items", + "tableTo": "notebooks", + "columnsFrom": [ + "notebook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mind_maps": { + "name": "mind_maps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "notebook_id": { + "name": "notebook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "tree_data": { + "name": "tree_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_mapping": { + "name": "chunk_mapping", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generating'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_mindmaps_notebook": { + "name": "idx_mindmaps_notebook", + "columns": [ + "notebook_id", + "updated_at" + ], + "isUnique": false + }, + "idx_mindmaps_version": { + "name": "idx_mindmaps_version", + "columns": [ + "notebook_id", + "version" + ], + "isUnique": false + } + }, + "foreignKeys": { + "mind_maps_notebook_id_notebooks_id_fk": { + "name": "mind_maps_notebook_id_notebooks_id_fk", + "tableFrom": "mind_maps", + "tableTo": "notebooks", + "columnsFrom": [ + "notebook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notebook_embedding_spaces": { + "name": "notebook_embedding_spaces", + "columns": { + "notebook_id": { + "name": "notebook_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backend": { + "name": "backend", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "dimensions": { + "name": "dimensions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "notebook_embedding_spaces_notebook_id_notebooks_id_fk": { + "name": "notebook_embedding_spaces_notebook_id_notebooks_id_fk", + "tableFrom": "notebook_embedding_spaces", + "tableTo": "notebooks", + "columnsFrom": [ + "notebook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notebooks": { + "name": "notebooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "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": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_notebooks_updated": { + "name": "idx_notebooks_updated", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notes": { + "name": "notes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "notebook_id": { + "name": "notebook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_notes_notebook": { + "name": "idx_notes_notebook", + "columns": [ + "notebook_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notes_notebook_id_notebooks_id_fk": { + "name": "notes_notebook_id_notebooks_id_fk", + "tableFrom": "notes", + "tableTo": "notebooks", + "columnsFrom": [ + "notebook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "quiz_sessions": { + "name": "quiz_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "quiz_id": { + "name": "quiz_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notebook_id": { + "name": "notebook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answers": { + "name": "answers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_questions": { + "name": "total_questions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "correct_count": { + "name": "correct_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_quiz_sessions_quiz": { + "name": "idx_quiz_sessions_quiz", + "columns": [ + "quiz_id", + "created_at" + ], + "isUnique": false + }, + "idx_quiz_sessions_notebook": { + "name": "idx_quiz_sessions_notebook", + "columns": [ + "notebook_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "quiz_sessions_quiz_id_quizzes_id_fk": { + "name": "quiz_sessions_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_sessions", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_sessions_notebook_id_notebooks_id_fk": { + "name": "quiz_sessions_notebook_id_notebooks_id_fk", + "tableFrom": "quiz_sessions", + "tableTo": "notebooks", + "columnsFrom": [ + "notebook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "quizzes": { + "name": "quizzes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "notebook_id": { + "name": "notebook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "questions_data": { + "name": "questions_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_mapping": { + "name": "chunk_mapping", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generating'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_quizzes_notebook": { + "name": "idx_quizzes_notebook", + "columns": [ + "notebook_id", + "updated_at" + ], + "isUnique": false + }, + "idx_quizzes_version": { + "name": "idx_quizzes_version", + "columns": [ + "notebook_id", + "version" + ], + "isUnique": false + } + }, + "foreignKeys": { + "quizzes_notebook_id_notebooks_id_fk": { + "name": "quizzes_notebook_id_notebooks_id_fk", + "tableFrom": "quizzes", + "tableTo": "notebooks", + "columnsFrom": [ + "notebook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/main/db/migrations/meta/_journal.json b/src/main/db/migrations/meta/_journal.json index fdbf467..be10baa 100644 --- a/src/main/db/migrations/meta/_journal.json +++ b/src/main/db/migrations/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1790594238292, "tag": "0021_aromatic_pyro", "breakpoints": true + }, + { + "idx": 22, + "version": "6", + "when": 1790753407323, + "tag": "0022_concerned_spyke", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/main/db/queries.ts b/src/main/db/queries.ts index 78e3eb4..55435ce 100644 --- a/src/main/db/queries.ts +++ b/src/main/db/queries.ts @@ -1,6 +1,7 @@ -import { eq, desc, and, sql } from 'drizzle-orm' +import { eq, desc, and, sql, like } from 'drizzle-orm' import { getDatabase, executeCheckpoint, dropNotebookVectorTable } from './index' import { chatSessions, chatMessages, notebooks, notes, documents, items } from './schema' +import { deriveSessionTitle } from '../../shared/utils/sessionTitle' import type { ChatMessageMetadata, ChatExecutionOutcome, @@ -13,9 +14,17 @@ import type { WorkspaceOverview } from '../../shared/types/workspace' // ==================== Chat Sessions ==================== /** - * 创建新的聊天会话 + * 创建新的聊天会话(#97)。 + * + * `title` 允许为空:会话先以一个占位标题出现,第一条用户消息到达时由 + * `deriveSessionTitleIfAuto` 推导出真正的标题。`titleIsAuto` 记录「还没被用户 + * 或推导改过」,手动改名会把它置为 false。 */ -export function createSession(notebookId: string, title: string, parentSessionId?: string) { +export function createSession( + notebookId: string, + title: string, + options: { parentSessionId?: string; titleIsAuto?: boolean } = {} +) { const db = getDatabase() const id = `session_${Date.now()}_${Math.random().toString(36).slice(2)}` const now = new Date() @@ -26,7 +35,10 @@ export function createSession(notebookId: string, title: string, parentSessionId id, notebookId, title, - parentSessionId, + titleIsAuto: options.titleIsAuto ?? false, + parentSessionId: options.parentSessionId, + // Newly created is newly opened: it is where the notebook returns to. + lastOpenedAt: now, createdAt: now, updatedAt: now }) @@ -37,22 +49,27 @@ export function createSession(notebookId: string, title: string, parentSessionId } /** - * 获取指定笔记本的活跃会话(栈顶) - * 每个笔记本只有一个 active session + * 打开笔记本时要回到的会话(#97)。 + * + * 多个会话可以有同一个 `status`,所以「active」不再能唯一定位一个会话;这里按 + * 「最近打开」排序,NULL(旧数据)退回 `updatedAt`。旧笔记本只有一个会话,两条路径 + * 都指向它,迁移因此无感。 */ -export function getActiveSessionByNotebook(notebookId: string) { +export function getMostRecentSessionByNotebook(notebookId: string) { const db = getDatabase() return db .select() .from(chatSessions) - .where(and(eq(chatSessions.notebookId, notebookId), eq(chatSessions.status, 'active'))) + .where(eq(chatSessions.notebookId, notebookId)) + .orderBy(desc(sql`coalesce(${chatSessions.lastOpenedAt}, ${chatSessions.updatedAt})`)) + .limit(1) .get() } /** - * 获取指定笔记本的所有会话 - * 按更新时间倒序排列 + * 获取指定笔记本的所有会话(#97)。 + * 最近打开的在前;旧数据没有 lastOpenedAt,退回更新时间。 */ export function getSessionsByNotebook(notebookId: string) { const db = getDatabase() @@ -61,12 +78,28 @@ export function getSessionsByNotebook(notebookId: string) { .select() .from(chatSessions) .where(eq(chatSessions.notebookId, notebookId)) - .orderBy(desc(chatSessions.updatedAt)) + .orderBy( + desc(sql`coalesce(${chatSessions.lastOpenedAt}, ${chatSessions.updatedAt})`), + desc(chatSessions.updatedAt) + ) .all() } /** - * 更新会话标题 + * 记录用户打开了某个会话(#97)。切换会话本身不写消息,所以要单独记一笔, + * 否则「上次用的是哪个」只能靠最后一条消息猜。 + */ +export function touchSession(sessionId: string) { + const db = getDatabase() + + db.update(chatSessions) + .set({ lastOpenedAt: new Date() }) + .where(eq(chatSessions.id, sessionId)) + .run() +} + +/** + * 更新会话标题。用户手动改名后,自动推导不再覆盖(`titleIsAuto` 置 false)。 */ export function updateSessionTitle(sessionId: string, title: string) { const db = getDatabase() @@ -74,12 +107,64 @@ export function updateSessionTitle(sessionId: string, title: string) { db.update(chatSessions) .set({ title, + titleIsAuto: false, updatedAt: new Date() }) .where(eq(chatSessions.id, sessionId)) .run() } +/** + * 第一条用户消息到达后,用消息内容给还带着占位标题的会话命名(#97)。 + * + * 只写一次:`titleIsAuto` 是「还没被命名」,不是「标题是自动生成的」;用户手动 + * 改过名就永远是 false,不会被下一条消息覆盖。 + */ +export function deriveSessionTitleIfAuto(sessionId: string, firstMessage: string) { + const db = getDatabase() + const session = getSessionById(sessionId) + if (!session?.titleIsAuto) return session ?? null + + const title = deriveSessionTitle(firstMessage) + if (!title) return session + + db.update(chatSessions) + .set({ title, titleIsAuto: false, updatedAt: new Date() }) + .where(eq(chatSessions.id, sessionId)) + .run() + + return { ...session, title, titleIsAuto: false } +} + +/** + * 在笔记本内按消息内容搜索(#97)。 + * + * 搜的是消息正文而不是会话标题:标题只是一个标签,真正要找的是「我在哪次对话里 + * 问过这件事」。返回消息连同它属于哪个会话,列表才能显示每一条命中来自哪里。 + */ +export function searchMessagesInNotebook(notebookId: string, query: string, limit = 20) { + const db = getDatabase() + // `%` / `_` in the query act as LIKE wildcards: a search box may be loose, and + // SQLite has no ESCAPE clause here to make an escaped backslash mean anything. + const pattern = `%${query}%` + + return db + .select({ + id: chatMessages.id, + sessionId: chatMessages.sessionId, + sessionTitle: chatSessions.title, + role: chatMessages.role, + content: chatMessages.content, + createdAt: chatMessages.createdAt + }) + .from(chatMessages) + .innerJoin(chatSessions, eq(chatMessages.sessionId, chatSessions.id)) + .where(and(eq(chatSessions.notebookId, notebookId), like(chatMessages.content, pattern))) + .orderBy(desc(chatMessages.createdAt)) + .limit(limit) + .all() +} + /** * 更新会话的检索范围(#94)。 * @@ -144,6 +229,19 @@ export function updateSessionTokens(sessionId: string, tokensToAdd: number) { return newTotal } +/** + * 笔记本里有多少个会话(#97)。自动切换用它给续接会话编号。 + */ +export function countSessionsByNotebook(notebookId: string): number { + const db = getDatabase() + const row = db + .select({ count: sql`count(*)` }) + .from(chatSessions) + .where(eq(chatSessions.notebookId, notebookId)) + .get() + return row?.count ?? 0 +} + /** * 更新会话的摘要和状态 */ diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index baba4e4..a59782d 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -40,23 +40,31 @@ export const chatSessions = sqliteTable( .notNull() .references(() => notebooks.id, { onDelete: 'cascade' }), title: text('title').notNull(), + // 标题是否为自动生成(#97)。新建会话先留一个占位标题,第一条用户消息到达时 + // 由它推导出真正的标题;用户手动改名后置为 false,推导就不再覆盖。 + titleIsAuto: integer('title_is_auto', { mode: 'boolean' }).notNull().default(false), // 自动切换 session 相关字段 summary: text('summary'), // 之前会话的摘要(如果是自动切换生成的) totalTokens: integer('total_tokens').notNull().default(0), // 当前会话累计 token 数 status: text('status', { enum: ['active', 'archived'] }) .notNull() - .default('active'), // 会话状态 + .default('active'), // 'archived' = 上下文上限触发的自动归档(#97),不代表会话不可用 parentSessionId: text('parent_session_id').references(() => chatSessions.id, { onDelete: 'set null' }), // 指向上一个被切换的 session // 这个会话用于回答的来源范围(#94)。NULL = 整个 notebook,与旧会话一致。 retrievalScope: text('retrieval_scope', { mode: 'json' }).$type(), + // 用户最近一次打开这个会话的时间(#97)。打开笔记本时回到「上次用的那个」就是 + // 按它排序;旧数据为 NULL 时退回 updatedAt,所以单会话笔记本迁移后行为不变。 + lastOpenedAt: integer('last_opened_at', { mode: 'timestamp' }), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull() }, (table) => ({ // 优化按笔记本查询会话的性能 - notebookIdx: index('idx_sessions_notebook').on(table.notebookId, table.updatedAt) + notebookIdx: index('idx_sessions_notebook').on(table.notebookId, table.updatedAt), + // 打开笔记本时取「最近使用的会话」(#97) + openedIdx: index('idx_sessions_notebook_opened').on(table.notebookId, table.lastOpenedAt) }) ) diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index 1dcd81f..6ba6393 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -173,10 +173,16 @@ export function registerChatHandlers( } // ==================== Chat Session ==================== + // A notebook holds many sessions (#97). Creating one always starts from a + // placeholder title; the first message names it, and a manual rename wins. ipcMain.handle( 'create-chat-session', validate(ChatSchemas.createSession, async (args) => { - return queries.createSession(args.notebookId, args.title) + // A caller that already has a name keeps it; an empty one is named by the + // first message (a `.trim()` guard, so a whitespace title does not count). + return queries.createSession(args.notebookId, args.title, { + titleIsAuto: !args.title.trim() + }) }) ) @@ -187,10 +193,28 @@ export function registerChatHandlers( }) ) + // Opening a notebook returns to the last session the user had open (#97). ipcMain.handle( 'get-active-session', validate(ChatSchemas.getActiveSession, async (args) => { - return queries.getActiveSessionByNotebook(args.notebookId) + return queries.getMostRecentSessionByNotebook(args.notebookId) + }) + ) + + // Switching sessions is what "last used" means; recording it is what keeps the + // session list ordered by use rather than by last message. + ipcMain.handle( + 'touch-session', + validate(ChatSchemas.touchSession, async (args) => { + queries.touchSession(args.sessionId) + return { success: true } + }) + ) + + ipcMain.handle( + 'search-messages', + validate(ChatSchemas.searchMessages, async (args) => { + return queries.searchMessagesInNotebook(args.notebookId, args.query.trim(), args.limit) }) ) @@ -241,6 +265,9 @@ export function registerChatHandlers( // 1. 保存用户消息 queries.createMessage(sessionId, 'user', content) + // 1.1 第一条消息给会话命名(#97)。放在保存之后:标题来自刚写下的那句话。 + queries.deriveSessionTitleIfAuto(sessionId, content) + const session = queries.getSessionById(sessionId) // 2. 这一轮要发给模型的上下文 diff --git a/src/main/ipc/validation.ts b/src/main/ipc/validation.ts index 11f802b..cf959fa 100644 --- a/src/main/ipc/validation.ts +++ b/src/main/ipc/validation.ts @@ -271,6 +271,18 @@ export const ChatSchemas = { title: z.string().min(1, '标题不能为空').max(200, '标题不能超过200个字符') }), + // 打开会话(#97):只记录「最近使用」,不改消息。 + touchSession: z.object({ + sessionId: z.string().min(1, '会话 ID 不能为空') + }), + + // 跨会话搜索消息(#97)。 + searchMessages: z.object({ + notebookId: z.string().min(1, '笔记本 ID 不能为空'), + query: z.string().min(1, '搜索内容不能为空').max(200, '搜索内容不能超过200个字符'), + limit: z.number().int().min(1).max(50).optional() + }), + setRetrievalScope: z.object({ sessionId: z.string().min(1, '会话 ID 不能为空'), scope: z.discriminatedUnion('type', [ diff --git a/src/main/services/SessionAutoSwitchService.ts b/src/main/services/SessionAutoSwitchService.ts index 0f7f30b..6fdfb63 100644 --- a/src/main/services/SessionAutoSwitchService.ts +++ b/src/main/services/SessionAutoSwitchService.ts @@ -1,6 +1,7 @@ import * as queries from '../db/queries' import { ConnectionManager } from '../models/ConnectionManager' import { collectMessageText } from '../../shared/utils/uiMessage' +import { continuedSessionTitle } from '../../shared/utils/sessionTitle' import Logger from '../../shared/utils/logger' /** @@ -43,7 +44,14 @@ export class SessionAutoSwitchService { } /** - * Switch session: generate summary, archive old session, create new session + * Switch session: generate summary, archive old session, create new session. + * + * The rollover is a fact the reader must be able to see (#97): the old session is + * kept in the notebook with `status: 'archived'` and the new one points back at it + * through `parentSessionId`. Nothing is hidden and nothing is deleted — the UI can + * name the archive boundary and open the previous session. The old code wrote a + * system message into the new session instead and never surfaced the old one; the + * message was the only trace of an event the reader could not act on. */ private async switchSession(oldSessionId: string): Promise { // 1. Get old session info @@ -60,19 +68,13 @@ export class SessionAutoSwitchService { queries.updateSessionSummary(oldSessionId, summary, 'archived') Logger.info('SessionAutoSwitch', 'Old session archived') - // 4. Create new session, set parent session ID + // 4. Create new session, set parent session ID. The title keeps the thread's + // identity and adds its position so the two rows are distinguishable. + const sequence = queries.countSessionsByNotebook(oldSession.notebookId) + 1 const newSession = queries.createSession( oldSession.notebookId, - oldSession.title, // Keep same title - oldSessionId // Set parent session ID to form chain - ) - - // 5. Add lightweight system message in new session (optional) - // Users shouldn't notice session switch, so simplify prompt - queries.createMessage( - newSession.id, - 'system', - `💡 Context optimized, conversation continues...` + continuedSessionTitle(oldSession.title, sequence), + { parentSessionId: oldSessionId } ) Logger.info('SessionAutoSwitch', `Created new session: ${newSession.id}`) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 66bb0bb..0ce1852 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,5 +1,10 @@ import { ElectronAPI } from '@electron-toolkit/preload' -import type { ChatSession, ChatMessage, ChatTurnEvent } from '../shared/types/chat' +import type { + ChatSession, + ChatMessage, + ChatTurnEvent, + ChatMessageSearchHit +} from '../shared/types/chat' import type { RetrievalScope } from '../shared/types/scope' import type { Notebook, @@ -199,6 +204,14 @@ declare global { getChatSessions: (notebookId: string) => Promise getActiveSession: (notebookId: string) => Promise updateSessionTitle: (sessionId: string, title: string) => Promise + /** Record that the user opened this session (#97). */ + touchSession: (sessionId: string) => Promise<{ success: boolean }> + /** Search message bodies across the notebook's sessions (#97). */ + searchMessages: ( + notebookId: string, + query: string, + limit?: number + ) => Promise setSessionScope: (sessionId: string, scope: RetrievalScope) => Promise<{ success: boolean }> deleteSession: (sessionId: string) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 1d51f9b..0273c11 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,7 +2,7 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron' import type { IpcRendererEvent } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import type { EmbeddingDownloadProgress } from '../shared/types' -import type { ChatTurnEvent } from '../shared/types/chat' +import type { ChatTurnEvent, ChatMessageSearchHit } from '../shared/types/chat' import type { RetrievalScope } from '../shared/types/scope' /** @@ -98,6 +98,15 @@ const api = { ipcRenderer.invoke('get-active-session', { notebookId }), updateSessionTitle: (sessionId: string, title: string) => ipcRenderer.invoke('update-session-title', { sessionId, title }), + // 记录「最近打开」,打开笔记本时靠它回到上次的会话(#97) + touchSession: (sessionId: string) => ipcRenderer.invoke('touch-session', { sessionId }), + // 跨会话搜索消息正文(#97) + searchMessages: ( + notebookId: string, + query: string, + limit?: number + ): Promise => + ipcRenderer.invoke('search-messages', { notebookId, query, limit }), setSessionScope: (sessionId: string, scope: RetrievalScope) => ipcRenderer.invoke('set-chat-session-scope', { sessionId, scope }), deleteSession: (sessionId: string) => ipcRenderer.invoke('delete-session', { sessionId }), diff --git a/src/renderer/src/components/notebook/ProcessPanel.tsx b/src/renderer/src/components/notebook/ProcessPanel.tsx index 0675ed4..bb6595f 100644 --- a/src/renderer/src/components/notebook/ProcessPanel.tsx +++ b/src/renderer/src/components/notebook/ProcessPanel.tsx @@ -17,6 +17,8 @@ import { FOCUS_CHAT_EVENT } from '../../lib/workspaceEvents' import MessageList, { type MessageListHandle } from './chat/MessageList' import NotebookHeader from './chat/NotebookHeader' import ScopeSelector from './chat/ScopeSelector' +import SessionSwitcher from './chat/SessionSwitcher' +import SessionContinuationNotice from './chat/SessionContinuationNotice' import { COMPOSER_GAP, COMPOSER_RESERVE, COMPOSER_RESERVE_VAR } from './chat/stickToBottom' import { Button } from '../ui/button' import { Textarea } from '../ui/textarea' @@ -231,13 +233,12 @@ function ProcessPanel({ } center={ /* - * Empty on purpose, and not omitted: the centre column is what keeps a - * wide drag surface between the two toggles. The notebook's name used to - * live here as a `text-sm` label; it is now the document header's job - * (`NotebookHeader`), and the window's tab strip already carries the name - * at all times, so nothing is lost by leaving this blank. + * The notebook's name lives in the document header below; the centre of + * the chat panel is the session switcher (#97), because the session is + * what the panel is showing. Leaving it empty kept the drag surface, but + * there is no longer a reason to. */ -