diff --git a/docs/CODEBASE-OVERVIEW.md b/docs/CODEBASE-OVERVIEW.md new file mode 100644 index 000000000..9442fab05 --- /dev/null +++ b/docs/CODEBASE-OVERVIEW.md @@ -0,0 +1,97 @@ +# NoteGen Codebase Overview + +This repository contains **three separate codebases**: + +1. **`note-gen` (primary app)**: Tauri + Next.js markdown note application with AI, sync, and local-first data. +2. **`machinelearning-samples`**: Upstream ML.NET sample repository (vendored). +3. **`YoloDotNet`**: Upstream .NET YOLO inference library (vendored). + +--- + +## 1) Primary App (`note-gen`) + +### Tech stack + +- UI/runtime: Next.js 15, React 19, TypeScript +- Desktop/mobile shell: Tauri v2 + Rust +- State management: Zustand stores in `src/stores` +- Persistence: + - SQLite via `@tauri-apps/plugin-sql` (`src/db`) + - App settings via `@tauri-apps/plugin-store` (`store.json`) + - Markdown/image files via Tauri FS APIs +- i18n: `next-intl` + `messages/*.json` +- UI primitives: Radix + custom components in `src/components/ui` + +### Runtime entry flow + +1. `src/app/page.tsx` decides initial route (`/core/main` for desktop, `/mobile/chat` for mobile). +2. `src/app/layout.tsx` sets global providers, CSS, i18n wrapper, and markdown-it compatibility shim. +3. `src/app/core/layout.tsx` or `src/app/mobile/layout.tsx` performs app initialization: + - settings init + - DB init (`initAllDatabases`) + - vector init + - image hosting init + - MCP init + - shortcut/init hooks + +### Core functional areas + +- **Recording/Chat**: `src/app/core/record/chat/*`, mirrored mobile routes under `src/app/mobile/*` +- **Writing/Editor**: `src/app/core/article/*` (`editor-wrapper.tsx` chooses markdown/image/folder views) +- **Main desktop workspace**: `src/app/core/main/page.tsx` (resizable left/editor/right panels) +- **Settings**: desktop and mobile settings routes under `src/app/core/setting/*` and `src/app/mobile/setting/*` + +### Data model (SQLite) + +Database initialization in `src/db/index.ts` sets up: + +- `chats` +- `marks` +- `notes` +- `tags` +- `vector` + +These tables support chat history, captured marks/snippets, note records, tagging, and embedding/search data. + +### Native (Rust/Tauri) side + +`src-tauri/src/lib.rs` registers plugins and command handlers. + +Main command groups: + +- WebDAV test/sync/backup (`webdav.rs`) +- MCP server process management (`mcp.rs`) +- Device ID and backup import/export +- Skill package import + +This means most heavy OS/system integration is intentionally placed in Rust commands, while UI logic stays in TypeScript. + +--- + +## 2) `machinelearning-samples` + +- This is a full ML.NET samples repository (C#/F#/CLI datasets and docs). +- It is not part of the NoteGen runtime path. +- Treat it as a standalone reference or bundled dependency tree unless your task explicitly targets it. + +--- + +## 3) `YoloDotNet` + +- This is a standalone .NET 8 YOLO inference library with its own solution and demos. +- It is not part of the `note-gen` app routing/runtime. +- Treat it as an independent subproject unless explicitly working on computer-vision integration tasks. + +--- + +## Recommended way to navigate this repo + +If your goal is to modify the NoteGen application, focus in this order: + +1. `src/app` (routes/layouts/pages) +2. `src/stores` (state + settings + app behavior) +3. `src/lib` (AI/sync/skill/MCP/business utilities) +4. `src/db` (local data schema and persistence functions) +5. `src-tauri/src` (native bridge commands) + +Ignore `machinelearning-samples` and `YoloDotNet` unless a feature directly references them. diff --git a/docs/MODULE-MAP.md b/docs/MODULE-MAP.md new file mode 100644 index 000000000..3f40ff544 --- /dev/null +++ b/docs/MODULE-MAP.md @@ -0,0 +1,72 @@ +# NoteGen Module Map + +This file is a practical “where things live” guide for contributors. + +## Top-level map + +- `src/app`: Next.js App Router pages/layouts (desktop + mobile) +- `src/components`: shared React UI components +- `src/stores`: Zustand state stores and app settings +- `src/lib`: business logic/services/utilities +- `src/db`: SQLite schema init and query helpers +- `src/config`: static config constants (shortcuts, emitters, exclusions) +- `src/contexts`: React providers/contexts +- `src/hooks`: app hooks +- `src-tauri`: Rust native layer and Tauri configuration +- `messages`: locale JSON files + +## `src/app` route modules + +- Root: + - `layout.tsx`: global wrappers/provider composition + - `page.tsx`: initial redirect logic (desktop/mobile) +- Desktop: + - `core/layout.tsx`: desktop init lifecycle and shell + - `core/main/page.tsx`: main 3-pane workspace + - `core/article/*`: editor stack (markdown/image/folder) + - `core/record/*`: chat + capture/record flows + - `core/setting/*`: full desktop settings sections +- Mobile: + - `mobile/layout.tsx`: mobile shell + hidden capture controls + - `mobile/chat/page.tsx`, `mobile/record/page.tsx`, `mobile/writing/page.tsx` + - `mobile/setting/pages/*`: mobile settings sections + +## `src/stores` responsibilities + +- `setting.ts`: central settings model (AI models, sync, themes, workspace paths, UI scale) +- `article.ts`: file tree/editor selection state +- `chat.ts`, `mark.ts`, `tag.ts`: core domain state +- `sync.ts`, `settingsSync.ts`, `webdav.ts`, `sync-confirm.ts`: sync and conflict UX +- `vector.ts`, `ragSettings.ts`: embedding/vector search settings and state +- `skills.ts`, `mcp.ts`: skill and MCP runtime state + +## `src/lib` service modules + +- `ai/*`: chat/completion/embedding integration +- `sync/*`: GitHub/Gitee/GitLab/Gitea sync adapters + conflict handling + autosync +- `mcp/*`: MCP init/client/server integration helpers +- `skills/*`: skill parsing/validation/execution helpers +- `rag.ts`, `bm25.ts`, `search-utils.ts`: retrieval/search logic +- `ocr.ts`, `pdf.ts`, `audio.ts`, `audio-converter.ts`: media/IO processing helpers + +## `src/db` local persistence + +- `index.ts`: single DB load + all-table initialization +- `chats.ts`, `marks.ts`, `notes.ts`, `tags.ts`, `vector.ts`: table creation + CRUD/select helpers + +## `src-tauri` native bridge + +- `src/lib.rs`: command registration and plugin wiring +- `webdav.rs`: WebDAV connectivity/backup/sync +- `mcp.rs`: stdio MCP process lifecycle and message piping +- `backup.rs`: app data import/export +- `device.rs`: device ID utilities +- `skills.rs`: skill package import +- `tauri.conf.json`: Tauri app/build/bundle/updater config + +## External bundled projects + +- `machinelearning-samples/`: standalone ML.NET sample repository +- `YoloDotNet/`: standalone .NET YOLO library + +These are independent projects and should usually be treated as read-only unless your task explicitly targets them. diff --git a/docs/READING-ORDER.md b/docs/READING-ORDER.md new file mode 100644 index 000000000..e1c6be727 --- /dev/null +++ b/docs/READING-ORDER.md @@ -0,0 +1,51 @@ +# Code Reading Order + +Use this order to understand the main app quickly. + +## System diagrams + +- Text version: `docs/SYSTEM-DIAGRAM-TEXT.md` +- HTML version: `docs/SYSTEM-DIAGRAM.html` + +## 1. App bootstrap + +1. `src/app/page.tsx` +2. `src/app/layout.tsx` +3. `src/app/core/layout.tsx` and `src/app/mobile/layout.tsx` + +This reveals how the app starts, chooses desktop/mobile, and initializes settings, databases, and services. + +## 2. Main user workflows + +- Desktop workspace: `src/app/core/main/page.tsx` +- Editor stack: `src/app/core/article/editor-wrapper.tsx` + `md-editor.tsx` +- Chat/record flow: `src/app/core/record/chat/index.tsx` +- Mobile chat route: `src/app/mobile/chat/page.tsx` + +## 3. State and configuration + +- Start with `src/stores/setting.ts`, then inspect feature stores (`article.ts`, `chat.ts`, `sync.ts`, `vector.ts`). +- Check `src/config/*` for constants and behavior flags. + +## 4. Data and persistence + +- `src/db/index.ts` then each table module in `src/db/*`. +- Trace where stores/lib functions call DB helpers. + +## 5. Service layer + +- `src/lib/ai/*` for model calls and AI behavior. +- `src/lib/sync/*` for Git/WebDAV sync and conflict logic. +- `src/lib/mcp/*` and `src/lib/skills/*` for tool/server integrations. + +## 6. Native capabilities + +- `src-tauri/src/lib.rs` command wiring. +- Then command implementations (`webdav.rs`, `mcp.rs`, `backup.rs`, `device.rs`, `skills.rs`). + +## 7. Ignore unless needed + +- `machinelearning-samples/` +- `YoloDotNet/` + +These are standalone embedded projects and not required to understand the main NoteGen runtime. diff --git a/docs/SYSTEM-DIAGRAM-TEXT.md b/docs/SYSTEM-DIAGRAM-TEXT.md new file mode 100644 index 000000000..0ef1d05b2 --- /dev/null +++ b/docs/SYSTEM-DIAGRAM-TEXT.md @@ -0,0 +1,61 @@ +# NoteGen System Diagram (Text) + +```text +┌───────────────────────────────────────────────────────────────────────────┐ +│ note-gen repo │ +├───────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────── Main App ────────────────────────────┐ │ +│ │ │ │ +│ │ UI / Routing Layer │ │ +│ │ src/app │ │ +│ │ ├─ page.tsx (desktop/mobile entry redirect) │ │ +│ │ ├─ core/* (desktop routes and layouts) │ │ +│ │ └─ mobile/* (mobile routes and layouts) │ │ +│ │ │ │ +│ │ │ uses │ │ +│ │ ▼ │ │ +│ │ State Layer │ │ +│ │ src/stores (Zustand) │ │ +│ │ ├─ setting/article/chat/sync/vector... │ │ +│ │ └─ orchestrates app behavior and user preferences │ │ +│ │ │ │ +│ │ │ calls │ │ +│ │ ▼ │ │ +│ │ Service Layer │ │ +│ │ src/lib │ │ +│ │ ├─ ai/* (chat/completion/embedding) │ │ +│ │ ├─ sync/* (GitHub/Gitee/GitLab/Gitea/WebDAV helpers) │ │ +│ │ ├─ mcp/* (MCP integration) │ │ +│ │ ├─ skills/* (skill parsing/execution) │ │ +│ │ └─ rag/pdf/ocr/audio/search utilities │ │ +│ │ │ │ +│ │ ┌───────────────┴────────────────┐ │ │ +│ │ ▼ ▼ │ │ +│ │ Local Data Native Capabilities │ │ +│ │ src/db src-tauri/src │ │ +│ │ ├─ chats/marks/notes/tags ├─ lib.rs (command registration) │ │ +│ │ ├─ vector table ├─ webdav.rs │ │ +│ │ └─ sqlite note.db ├─ mcp.rs │ │ +│ │ ├─ backup.rs │ │ +│ │ └─ device/skills modules │ │ +│ │ │ │ +│ │ Cross-cutting │ │ +│ │ ├─ messages/*.json (i18n) │ │ +│ │ ├─ src/components/* (UI components) │ │ +│ │ └─ Tauri plugins (store/fs/sql/http/shortcut/updater/...) │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Standalone bundled subprojects (normally not part of main runtime): │ +│ - machinelearning-samples/ │ +│ - YoloDotNet/ │ +│ │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +## Legend + +- Top-to-bottom flow: UI → Store → Service → (DB / Tauri Commands) +- `src-tauri` commands are invoked by frontend code through Tauri invoke APIs. +- `machinelearning-samples` and `YoloDotNet` are independent projects in this repo. diff --git a/docs/SYSTEM-DIAGRAM.html b/docs/SYSTEM-DIAGRAM.html new file mode 100644 index 000000000..13252631f --- /dev/null +++ b/docs/SYSTEM-DIAGRAM.html @@ -0,0 +1,140 @@ + + + + + + NoteGen System Diagram + + + +
+

NoteGen System Diagram

+

Flow: src/app → src/stores → src/lib → (src/db and src-tauri commands)

+ +
+
+
UI and Routing (src/app)
+
    +
  • page.tsx chooses desktop or mobile path
  • +
  • core/* for desktop features
  • +
  • mobile/* for mobile features
  • +
+
+ +
+ +
+
State (src/stores)
+
    +
  • setting/article/chat/sync/vector stores
  • +
  • Coordinates user state and feature behavior
  • +
+
+ +
+ +
+
Services (src/lib)
+
    +
  • ai/*, sync/*, mcp/*, skills/*
  • +
  • search/rag/pdf/ocr/audio utilities
  • +
+
+ +
+ +
+
+
Local Data (src/db)
+
    +
  • SQLite note.db
  • +
  • chats, marks, notes, tags, vector tables
  • +
+
+ +
+
Native Layer (src-tauri/src)
+
    +
  • Rust Tauri command handlers
  • +
  • webdav, mcp, backup, device, skills modules
  • +
+
+
+ +
+
Bundled Standalone Projects
+
    +
  • machinelearning-samples/
  • +
  • YoloDotNet/
  • +
  • Independent from main NoteGen runtime
  • +
+
+
+
+ + diff --git a/messages/en.json b/messages/en.json index 048598315..786b3f3fb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1551,6 +1551,16 @@ "convert": "Convert Article", "description": "The current note is generated by AI and cannot be edited. Convert the current note to an article (generate a local file) for secondary creation in the writing page.", "filename": "Filename", + "organizeAllAs": "Organize current tag", + "organizeSingleAs": "Organize this recording", + "selectedRecording": "Current scope: single recording", + "currentScope": "Current scope", + "templateContent": "Template content", + "recordRange": "Record range", + "filterThinkingContent": "Remove thinking content from records", + "startOrganizeAll": "Organize current tag", + "startOrganizeSingle": "Organize this recording", + "manageTemplate": "Manage template", "selectFolder": "Select folder", "rootDirectory": "Root directory", "deleteTag": "Delete current tag, records and notes (can be restored from trash)", @@ -1627,6 +1637,8 @@ "copyLink": "Copy Link", "copied": "Copied to clipboard!", "regenerateDesc": "Regenerate Description", + "reconvertStt": "Reconvert Speech to Text", + "reconvertSttProcessing": "Reconverting Speech to Text...", "viewFolder": "View Folder", "viewFile": "View Original File", "deleteForever": "Delete Forever", @@ -1655,6 +1667,8 @@ "closeTrash": "Close Trash", "selectAll": "Select All", "deselectAll": "Deselect All", + "organizeCurrentTag": "Organize current tag", + "organizeThisRecording": "Organize this recording", "selectedCount": "{count} items selected", "visibleCount": "{count} records", "moveSelectedTags": "Move selected {count} items", @@ -1684,10 +1698,16 @@ }, "note": { "organizeAs": "Organize as", + "organizeAllAs": "Organize current tag", + "organizeSingleAs": "Organize this recording", + "selectedRecording": "Current scope: single recording", + "currentScope": "Current scope", "template": "Template", "setting": "Settings", "confirm": "确认", "cancel": "取消", + "startOrganizeAll": "Organize current tag", + "startOrganizeSingle": "Organize this recording", "removeThinking": "移除思考过程", "stop": "Stop" } @@ -1752,10 +1772,16 @@ }, "note": { "organize": "Organize", + "organizeAllAs": "Organize current tag", + "organizeSingleAs": "Organize this recording", + "selectedRecording": "Current scope: single recording", + "currentScope": "Current scope", "writing": "Write", "convert": "Convert Article", "description": "The current note is generated by AI and cannot be edited. Convert the current note to an article (generate a local file) for secondary creation in the writing page.", "filename": "Filename", + "startOrganizeAll": "Organize current tag", + "startOrganizeSingle": "Organize this recording", "selectFolder": "Select folder", "rootDirectory": "Root directory", "deleteTag": "Delete current tag, records and notes (can be restored from trash)", diff --git a/messages/ja.json b/messages/ja.json index 839a7a666..727aa6ecd 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -1563,6 +1563,16 @@ "convert": "文章に変換", "description": "現在のノートはAIによって生成されており、編集できません。現在のノートを記事に変換(ローカルファイルの生成)して、執筆ページで二次創作を行うことができます。", "filename": "ファイル名", + "organizeAllAs": "現在のタグを整理", + "organizeSingleAs": "この録音を整理", + "selectedRecording": "現在の範囲: 単一録音", + "currentScope": "現在の範囲", + "templateContent": "テンプレート内容", + "recordRange": "記録範囲を選択", + "filterThinkingContent": "思考の記録を削除します", + "startOrganizeAll": "現在のタグを整理", + "startOrganizeSingle": "この録音を整理", + "manageTemplate": "管理テンプレート", "selectFolder": "フォルダを選択", "rootDirectory": "ルートディレクトリ", "deleteTag": "現在のタグ、記録、ノートを削除(ゴミ箱から復元可能)", @@ -1620,6 +1630,8 @@ "copyLink": "リンクをコピー", "copied": "クリップボードにコピーしました!", "regenerateDesc": "説明を再生成", + "reconvertStt": "音声を再認識", + "reconvertSttProcessing": "音声を再認識中...", "viewFolder": "フォルダで表示", "viewFile": "元ファイルを表示", "deleteForever": "完全に削除", @@ -1633,6 +1645,8 @@ "deleteSelected": "選択した{count}項目を削除", "deleteSelectedForever": "選択した{count}項目を完全削除", "organizeNotes": "ノート整理", + "organizeCurrentTag": "現在のタグを整理", + "organizeThisRecording": "この録音を整理", "organizeSuccess": "ノート整理成功:{title}", "organizeError": "ノート整理失敗", "currentTag": "現在のタグ", @@ -1650,10 +1664,16 @@ }, "note": { "organizeAs": "整理先", + "organizeAllAs": "現在のタグを整理", + "organizeSingleAs": "この録音を整理", + "selectedRecording": "現在の範囲: 単一録音", + "currentScope": "現在の範囲", "template": "テンプレート", "setting": "設定", "confirm": "确认", "cancel": "取消", + "startOrganizeAll": "現在のタグを整理", + "startOrganizeSingle": "この録音を整理", "removeThinking": "移除思考过程", "stop": "停止" } @@ -1867,10 +1887,16 @@ }, "note": { "organize": "整理", + "organizeAllAs": "現在のタグを整理", + "organizeSingleAs": "この録音を整理", + "selectedRecording": "現在の範囲: 単一録音", + "currentScope": "現在の範囲", "writing": "執筆", "convert": "記事に変換", "description": "現在のノートはAIによって生成されており、編集できません。現在のノートを記事に変換(ローカルファイルを生成)して、執筆ページで二次創作を行うことができます。", "filename": "ファイル名", + "startOrganizeAll": "現在のタグを整理", + "startOrganizeSingle": "この録音を整理", "selectFolder": "フォルダを選択", "rootDirectory": "ルートディレクトリ", "deleteTag": "現在のタグ、記録、ノートを削除(ゴミ箱から復元可能)", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 9b5e099da..8d33de4a9 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -1578,6 +1578,16 @@ "convert": "Converter Artigo", "description": "A nota atual é gerada por IA e não pode ser editada. Converta a nota atual em um artigo (gere um arquivo local) para edição posterior na página de escrita.", "filename": "Nome do Arquivo", + "organizeAllAs": "Organizar tag atual", + "organizeSingleAs": "Organizar esta gravacao", + "selectedRecording": "Escopo atual: gravacao unica", + "currentScope": "Escopo atual", + "templateContent": "Conteúdo do template", + "recordRange": "Intervalo de registros", + "filterThinkingContent": "Remover conteúdo de 'pensamento' dos registros", + "startOrganizeAll": "Organizar tag atual", + "startOrganizeSingle": "Organizar esta gravacao", + "manageTemplate": "Gerenciar template", "selectFolder": "Selecionar pasta", "rootDirectory": "Diretório raiz", "deleteTag": "Excluir tag atual, registros e notas (pode ser restaurado da lixeira)", @@ -1642,6 +1652,8 @@ "copyLink": "Copiar Link", "copied": "Copiado para a área de transferência!", "regenerateDesc": "Gerar Descrição Novamente", + "reconvertStt": "Reconverter Áudio em Texto", + "reconvertSttProcessing": "Reconvertendo Áudio em Texto...", "viewFolder": "Ver na Pasta", "viewFile": "Ver Arquivo Original", "deleteForever": "Excluir Permanentemente", @@ -1663,6 +1675,8 @@ "deleteSelected": "Excluir {count} itens selecionados", "deleteSelectedForever": "Excluir {count} itens selecionados permanentemente", "organizeNotes": "Organizar Notas", + "organizeCurrentTag": "Organizar tag atual", + "organizeThisRecording": "Organizar esta gravacao", "organizeSuccess": "Notas organizadas com sucesso: {title}", "organizeError": "Falha ao organizar notas", "currentTag": "Tag Atual", @@ -1680,10 +1694,16 @@ }, "note": { "organizeAs": "Organizar como", + "organizeAllAs": "Organizar tag atual", + "organizeSingleAs": "Organizar esta gravacao", + "selectedRecording": "Escopo atual: gravacao unica", + "currentScope": "Escopo atual", "template": "Modelo", "setting": "Configurações", "confirm": "Confirmar", "cancel": "Cancelar", + "startOrganizeAll": "Organizar tag atual", + "startOrganizeSingle": "Organizar esta gravacao", "removeThinking": "Remover processo de raciocínio", "stop": "Parar" }, @@ -1745,10 +1765,16 @@ }, "note": { "organize": "Organizar", + "organizeAllAs": "Organizar tag atual", + "organizeSingleAs": "Organizar esta gravacao", + "selectedRecording": "Escopo atual: gravacao unica", + "currentScope": "Escopo atual", "writing": "Escrever", "convert": "Converter Artigo", "description": "A nota atual é gerada por IA e não pode ser editada. Converta a nota atual em um artigo (gere um arquivo local) para edição posterior na página de escrita.", "filename": "Nome do Arquivo", + "startOrganizeAll": "Organizar tag atual", + "startOrganizeSingle": "Organizar esta gravacao", "selectFolder": "Selecionar pasta", "rootDirectory": "Diretório raiz", "deleteTag": "Excluir tag atual, registros e notas (pode ser restaurado da lixeira)", diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 78caf624d..7b3a1ba44 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -1562,6 +1562,16 @@ "convert": "轉化文章", "description": "當前的筆記是由 AI 生成且無法編輯,將當前筆記轉化為文章(生成本地文件),可在寫作頁面中進行二次創作。", "filename": "檔案名", + "organizeAllAs": "整理目前標籤", + "organizeSingleAs": "整理這段錄音", + "selectedRecording": "目前範圍: 單條錄音", + "currentScope": "目前範圍", + "templateContent": "模板內容", + "recordRange": "記錄選擇範圍", + "filterThinkingContent": "移除記錄中的思考", + "startOrganizeAll": "開始整理目前標籤", + "startOrganizeSingle": "開始整理這段錄音", + "manageTemplate": "管理模板", "selectFolder": "選擇文件夾", "rootDirectory": "根目錄", "deleteTag": "刪除當前標籤、記錄和筆記(回收站可恢復)", @@ -1619,6 +1629,8 @@ "copyLink": "複製連結", "copied": "已複製到剪切板!", "regenerateDesc": "重新生成描述", + "reconvertStt": "重新語音識別", + "reconvertSttProcessing": "重新語音識別中...", "viewFolder": "查看目錄", "viewFile": "查看原文件", "deleteForever": "徹底刪除", @@ -1632,6 +1644,8 @@ "deleteSelected": "刪除選中的 {count} 項", "deleteSelectedForever": "徹底刪除選中的 {count} 項", "organizeNotes": "整理筆記", + "organizeCurrentTag": "整理目前標籤", + "organizeThisRecording": "整理這段錄音", "organizeSuccess": "筆記整理成功:{title}", "organizeError": "整理筆記失敗", "currentTag": "當前標籤", @@ -1647,6 +1661,21 @@ "list": { "title": "記錄" }, + "note": { + "organizeAs": "整理為", + "organizeAllAs": "整理目前標籤", + "organizeSingleAs": "整理這段錄音", + "selectedRecording": "目前範圍: 單條錄音", + "currentScope": "目前範圍", + "template": "模板", + "setting": "設定", + "confirm": "確認", + "cancel": "取消", + "startOrganizeAll": "開始整理目前標籤", + "startOrganizeSingle": "開始整理這段錄音", + "removeThinking": "移除思考過程", + "stop": "停止" + }, "imageGallery": { "expand": "展开", "collapse": "收起" @@ -1861,10 +1890,16 @@ }, "note": { "organize": "整理", + "organizeAllAs": "整理目前標籤", + "organizeSingleAs": "整理這段錄音", + "selectedRecording": "目前範圍: 單條錄音", + "currentScope": "目前範圍", "writing": "寫作", "convert": "轉化文章", "description": "當前的筆記是由 AI 生成且無法編輯,將當前筆記轉化為文章(生成本地文件),可在寫作頁面中進行二次創作。", "filename": "檔案名", + "startOrganizeAll": "開始整理目前標籤", + "startOrganizeSingle": "開始整理這段錄音", "selectFolder": "選擇文件夾", "rootDirectory": "根目錄", "deleteTag": "刪除當前標籤、記錄和筆記(回收站可恢復)", diff --git a/messages/zh.json b/messages/zh.json index 9206d0ba2..9307b3db1 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -1529,6 +1529,16 @@ "convert": "转化文章", "description": "当前的笔记是由 AI 生成且无法编辑,将当前笔记转化为文章(生成本地文件),可在写作页面中进行二次创作。", "filename": "文件名", + "organizeAllAs": "整理当前标签", + "organizeSingleAs": "整理此录音", + "selectedRecording": "当前范围: 单条录音", + "currentScope": "当前范围", + "templateContent": "模板内容", + "recordRange": "记录选择范围", + "filterThinkingContent": "移除记录中的思考", + "startOrganizeAll": "开始整理当前标签", + "startOrganizeSingle": "开始整理此录音", + "manageTemplate": "管理模板", "selectFolder": "选择文件夹", "rootDirectory": "根目录", "deleteTag": "删除当前标签、记录和笔记(回收站可恢复)", @@ -1599,6 +1609,8 @@ "trash": "回收站", "closeTrash": "关闭回收站", "organizeNotes": "整理笔记", + "organizeCurrentTag": "整理当前标签", + "organizeThisRecording": "整理此录音", "organizeSuccess": "笔记整理成功:{title}", "organizeError": "整理笔记失败", "currentTag": "当前标签", @@ -1610,6 +1622,8 @@ "copyLink": "复制链接", "copied": "已复制到剪切板!", "regenerateDesc": "重新生成描述", + "reconvertStt": "重新语音识别", + "reconvertSttProcessing": "重新语音识别中...", "viewFolder": "查看目录", "viewFile": "查看原文件", "deleteForever": "彻底删除", @@ -1651,6 +1665,21 @@ "last7Days": "最近7天", "last30Days": "最近30天" } + }, + "note": { + "organizeAs": "整理为", + "organizeAllAs": "整理当前标签", + "organizeSingleAs": "整理此录音", + "selectedRecording": "当前范围: 单条录音", + "currentScope": "当前范围", + "template": "模板", + "setting": "设置", + "confirm": "确认", + "cancel": "取消", + "startOrganizeAll": "开始整理当前标签", + "startOrganizeSingle": "开始整理此录音", + "removeThinking": "移除思考过程", + "stop": "Stop" } }, "chat": { diff --git a/src/app/core/main/editor/markdown/md-editor-wrapper.tsx b/src/app/core/main/editor/markdown/md-editor-wrapper.tsx index 3b3373312..9f6dc399b 100644 --- a/src/app/core/main/editor/markdown/md-editor-wrapper.tsx +++ b/src/app/core/main/editor/markdown/md-editor-wrapper.tsx @@ -203,6 +203,19 @@ export function MdEditor({ tabContentsRef, filePath }: MdEditorProps) { const isThisFile = currentArticlePathRef.current === filePath || storeActivePath === filePath if (currentArticle && currentArticle.length > 0 && currentArticle !== initialContent && isThisFile) { + if (aiStreaming && activeFilePath === filePath) { + if (tabContentsRef.current) { + tabContentsRef.current[filePath] = currentArticle + } + + if (isLoadingRef.current) { + setIsLoading(false) + isLoadingRef.current = false + } + + return + } + // Bug fix: Set expected content BEFORE updating initialContent // This ensures handleContentChange knows what to expect expectedContentRef.current = currentArticle @@ -233,7 +246,7 @@ export function MdEditor({ tabContentsRef, filePath }: MdEditorProps) { // Mark as initialized for empty files so user can start typing contentInitializedRef.current = true } - }, [currentArticle, filePath, tabContentsRef, initialContent, justPulledFile]) + }, [activeFilePath, aiStreaming, currentArticle, filePath, tabContentsRef, initialContent, justPulledFile]) // Handle content changes - only save if this is the active file const handleContentChange = useCallback((content: string) => { diff --git a/src/app/core/main/editor/markdown/sync/history-sheet.tsx b/src/app/core/main/editor/markdown/sync/history-sheet.tsx index d04e5dd69..4b6163df2 100644 --- a/src/app/core/main/editor/markdown/sync/history-sheet.tsx +++ b/src/app/core/main/editor/markdown/sync/history-sheet.tsx @@ -34,6 +34,7 @@ interface HistorySheetProps { } export function HistorySheet({ editor }: HistorySheetProps) { + const t = useTranslations('article.footer.history') const { activeFilePath } = useArticleStore() const [isOpen, setIsOpen] = useState(false) const [history, setHistory] = useState([]) diff --git a/src/app/core/main/editor/markdown/tiptap-editor.tsx b/src/app/core/main/editor/markdown/tiptap-editor.tsx index ab802f499..6b9a8cc82 100644 --- a/src/app/core/main/editor/markdown/tiptap-editor.tsx +++ b/src/app/core/main/editor/markdown/tiptap-editor.tsx @@ -1588,16 +1588,22 @@ export function TipTapEditor({ // user edits (e.g., when switching back to a previously edited tab) // Bug fix: Also check that we're initializing for the correct file path if (!isInitializedRef.current) { + isInitializedRef.current = true + isReadyRef.current = false + // Use setTimeout to avoid flushSync conflict during React render setTimeout(() => { // Check if the file path is still the same (handle race condition) if (activeFilePath !== currentPath) return if (initialContent) { + externalUpdateCounterRef.current++ editor.commands.setContent(initialContent || '', { contentType: 'markdown' }) + setTimeout(() => { + externalUpdateCounterRef.current = Math.max(0, externalUpdateCounterRef.current - 1) + }, 100) } - // Mark as initialized to allow subsequent content updates - isInitializedRef.current = true + // Bug fix: Mark editor as ready AFTER content is set // This prevents onUpdate from firing with empty content during init isReadyRef.current = true @@ -1869,7 +1875,14 @@ export function TipTapEditor({ // Handle external content updates (e.g., from Agent tools) useEffect(() => { - const handleExternalUpdate = (newContent: string) => { + const handleExternalUpdate = (payload: string | { content: string; targetFilePath?: string }) => { + const newContent = typeof payload === 'string' ? payload : payload.content + const targetFilePath = typeof payload === 'string' ? undefined : payload.targetFilePath + + if (targetFilePath && targetFilePath !== activeFilePath) { + return + } + if (editor && externalUpdateCounterRef.current === 0) { // Bug fix: Skip if content hasn't actually changed const currentContent = editor.getMarkdown() @@ -1897,7 +1910,7 @@ export function TipTapEditor({ return () => { emitter.off('external-content-update', handleExternalUpdate as any) } - }, [editor]) + }, [editor, activeFilePath]) // Set editable state useEffect(() => { diff --git a/src/app/core/main/mark/mark-actions.tsx b/src/app/core/main/mark/mark-actions.tsx index 73528d36e..b0869c5e6 100644 --- a/src/app/core/main/mark/mark-actions.tsx +++ b/src/app/core/main/mark/mark-actions.tsx @@ -5,13 +5,13 @@ import { Trash2, XCircle, Sparkles } from "lucide-react" import { useTranslations } from "next-intl" import useMarkStore from "@/stores/mark" import { OrganizeNotes } from "./organize-notes" -import { useEffect, useRef } from "react" +import { useEffect } from "react" import { MarkFilterPopover } from "./mark-filter-popover" +import emitter from "@/lib/emitter" export function MarkActions() { const t = useTranslations('record.mark') const { trashState, setTrashState, initRecordFilters } = useMarkStore() - const organizeRef = useRef<{ openOrganize: () => void }>(null) useEffect(() => { initRecordFilters() @@ -22,7 +22,7 @@ export function MarkActions() { } const handleOrganize = () => { - organizeRef.current?.openOrganize() + emitter.emit('open-organize-notes', undefined) } return ( @@ -31,7 +31,7 @@ export function MarkActions() { } - tooltipText={t('toolbar.organizeNotes')} + tooltipText={t('toolbar.organizeCurrentTag')} onClick={handleOrganize} variant="ghost" side="bottom" @@ -45,7 +45,7 @@ export function MarkActions() { variant={trashState ? "default" : "ghost"} side="bottom" /> - + ) } diff --git a/src/app/core/main/mark/mark-item.tsx b/src/app/core/main/mark/mark-item.tsx index 0987e7d67..4a4ff8bb6 100644 --- a/src/app/core/main/mark/mark-item.tsx +++ b/src/app/core/main/mark/mark-item.tsx @@ -20,8 +20,9 @@ import useTagStore from "@/stores/tag"; import { LocalImage } from "@/components/local-image"; import { fetchAiDesc } from "@/lib/ai/description"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; import { appDataDir } from "@tauri-apps/api/path"; -import { CheckSquare, ImageUp, RefreshCw, Settings2, Square } from "lucide-react"; +import { CheckSquare, ImageUp, LoaderCircle, RefreshCw, Settings2, Sparkles, Square } from "lucide-react"; import { toast } from "@/hooks/use-toast"; import { openPath, revealItemInDir } from "@tauri-apps/plugin-opener"; import { Textarea } from "@/components/ui/textarea"; @@ -151,7 +152,8 @@ export const MarkWrapper = React.memo(({mark, variant = 'list'}: {mark: Mark, va const t = useTranslations('record.mark.type'); const todoT = useTranslations('record.mark.todo'); const recordingT = useTranslations('recording'); - const { isMultiSelectMode, selectedMarkIds, toggleMarkSelection } = useMarkStore(); + const toolbarT = useTranslations('record.mark.toolbar'); + const { isMultiSelectMode, selectedMarkIds, toggleMarkSelection, queues } = useMarkStore(); const { recordTextSize, sttModel } = useSettingStore(); const { fetchMarks } = useMarkStore(); const router = useRouter(); @@ -161,6 +163,13 @@ export const MarkWrapper = React.memo(({mark, variant = 'list'}: {mark: Mark, va const lineHeight = useMemo(() => getLineHeight(recordTextSize), [recordTextSize]) const shouldShowRecordingAction = mark.type === 'recording' && mark.content === NO_TRANSCRIPTION_MESSAGE const itemContent = useMemo(() => getMarkListItemContent(mark), [mark]) + const isReconvertingStt = useMemo(() => { + return queues.some(queue => + queue.type === 'recording' && + queue.tagId === mark.tagId && + queue.queueId.startsWith(`reconvert-stt-${mark.id}-`) + ) + }, [queues, mark.id, mark.tagId]) const todoPriorityDotClass = itemContent.todo ? itemContent.todo.priority === 'high' @@ -429,6 +438,12 @@ export const MarkWrapper = React.memo(({mark, variant = 'list'}: {mark: Mark, va {mark.url && (
+ {isReconvertingStt && ( +
+ + {toolbarT('reconvertSttProcessing')} +
+ )}
)} @@ -485,6 +500,7 @@ MarkWrapper.displayName = 'MarkWrapper' export const MarkItem = React.memo(({mark, variant = 'list'}: {mark: Mark, variant?: MarkItemVariant}) => { const t = useTranslations(); const isMobile = useIsMobile() + const { sttModel } = useSettingStore() const { marks, fetchMarks, @@ -494,8 +510,11 @@ export const MarkItem = React.memo(({mark, variant = 'list'}: {mark: Mark, varia selectedMarkIds, clearSelection, highlightedMarkId, + addQueue, + removeQueue } = useMarkStore() const { tags, currentTagId, fetchTags, getCurrentTag } = useTagStore() + const [isReconvertSttLoading, setIsReconvertSttLoading] = useState(false) const handleDragStart = useCallback((e: React.DragEvent) => { if (isMultiSelectMode) { @@ -593,6 +612,84 @@ export const MarkItem = React.memo(({mark, variant = 'list'}: {mark: Mark, varia fetchMarks() }, [mark, fetchMarks]) + const handleReconvertStt = useCallback(async (e?: React.MouseEvent) => { + e?.stopPropagation() + + if (mark.type !== 'recording' || !mark.url || isReconvertSttLoading) { + return + } + + if (!sttModel) { + toast({ + title: t('recording.error'), + description: t('recording.noModelConfigured'), + variant: 'destructive' + }) + return + } + + const queueId = `reconvert-stt-${mark.id}-${Date.now()}` + addQueue({ + queueId, + tagId: mark.tagId, + type: 'recording', + progress: t('record.mark.toolbar.reconvertSttProcessing'), + startTime: Date.now() + }) + + setIsReconvertSttLoading(true) + try { + const fileData = await readFile(mark.url, { baseDir: BaseDirectory.AppData }) + const extension = mark.url.split('.').pop()?.toLowerCase() + const mimeType = extension === 'wav' ? 'audio/wav' : + extension === 'mp3' ? 'audio/mpeg' : + extension === 'm4a' || extension === 'mp4' ? 'audio/mp4' : + extension === 'ogg' ? 'audio/ogg' : + extension === 'webm' ? 'audio/webm' : + extension === 'flac' ? 'audio/flac' : + extension === 'aac' ? 'audio/aac' : + 'audio/mpeg' + + const buffer = fileData.buffer.slice(fileData.byteOffset, fileData.byteOffset + fileData.byteLength) as ArrayBuffer + const audioBlob = new Blob([buffer], { type: mimeType }) + + const transcription = (await transcribeRecording(audioBlob)).trim() + const content = transcription || t('recording.noContentDetected') + + await updateMark({ + ...mark, + content, + desc: content.substring(0, 100) + }) + await fetchMarks() + + toast({ + title: t('recording.success'), + description: t('recording.transcriptionSuccess') + }) + } catch (error) { + console.error('reconvert STT failed:', error) + toast({ + title: t('recording.error'), + description: error instanceof Error ? error.message : t('recording.transcriptionError'), + variant: 'destructive' + }) + } finally { + removeQueue(queueId) + setIsReconvertSttLoading(false) + } + }, [mark, isReconvertSttLoading, sttModel, t, fetchMarks, addQueue, removeQueue]) + + const handleOrganizeThisRecording = useCallback((e?: React.MouseEvent) => { + e?.stopPropagation() + + if (mark.type !== 'recording') { + return + } + + emitter.emit('open-organize-notes', { marks: [mark] }) + }, [mark]) + const handelShowInFolder = useCallback(async (e?: React.MouseEvent) => { e?.stopPropagation() try { @@ -660,6 +757,32 @@ export const MarkItem = React.memo(({mark, variant = 'list'}: {mark: Mark, varia onDragEnd={handleDragEnd} > + {mark.type === 'recording' && mark.url ? ( +
+ + +
+ ) : null}
{t('record.mark.toolbar.regenerateDesc')} + + {t('record.mark.toolbar.organizeThisRecording')} + + + {isReconvertSttLoading ? t('record.mark.toolbar.reconvertSttProcessing') : t('record.mark.toolbar.reconvertStt')} + {t('record.mark.toolbar.viewFolder')} diff --git a/src/app/core/main/mark/mark-list.tsx b/src/app/core/main/mark/mark-list.tsx index ff826989d..eb9982350 100644 --- a/src/app/core/main/mark/mark-list.tsx +++ b/src/app/core/main/mark/mark-list.tsx @@ -35,10 +35,20 @@ export const MarkList = React.memo(function MarkList() { const filterSummary = React.useMemo(() => buildRecordFilterSummary(effectiveFilters), [effectiveFilters]) React.useEffect(() => { - setVisibleMarkIds(filteredMarks.map((mark: Mark) => mark.id)) - return () => setVisibleMarkIds([]) + const nextVisibleMarkIds = filteredMarks.map((mark: Mark) => mark.id) + const currentVisibleMarkIds = useMarkStore.getState().visibleMarkIds + const hasSameLength = currentVisibleMarkIds.length === nextVisibleMarkIds.length + const hasSameValues = hasSameLength && currentVisibleMarkIds.every((id, index) => id === nextVisibleMarkIds[index]) + + if (!hasSameValues) { + setVisibleMarkIds(nextVisibleMarkIds) + } }, [filteredMarks, setVisibleMarkIds]) + React.useEffect(() => { + return () => setVisibleMarkIds([]) + }, [setVisibleMarkIds]) + const view = (() => { switch (recordViewMode) { case 'compact': diff --git a/src/app/core/main/mark/mark-mobile-actions.tsx b/src/app/core/main/mark/mark-mobile-actions.tsx index 57272f9a6..34fe2fc4e 100644 --- a/src/app/core/main/mark/mark-mobile-actions.tsx +++ b/src/app/core/main/mark/mark-mobile-actions.tsx @@ -1,7 +1,7 @@ 'use client' import { useTranslations } from 'next-intl' -import { MoreVertical, FolderOpen, File, Link2, RefreshCw, Trash2, RotateCcw, XCircle } from 'lucide-react' +import { MoreVertical, FolderOpen, File, Link2, RefreshCw, Trash2, RotateCcw, XCircle, AudioLines, Sparkles } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -27,6 +27,9 @@ interface MarkMobileActionsProps { onTransfer: (tagId: number, e?: React.MouseEvent) => void onCopyLink: (e?: React.MouseEvent) => void onRegenerateDesc: (e?: React.MouseEvent) => void + onOrganizeMark: (e?: React.MouseEvent) => void + onReconvertStt: (e?: React.MouseEvent) => void + isReconvertSttLoading?: boolean onShowInFolder: (e?: React.MouseEvent) => void onShowInFile: (e?: React.MouseEvent) => void onRestore: (e?: React.MouseEvent) => void @@ -44,6 +47,9 @@ export function MarkMobileActions({ onTransfer, onCopyLink, onRegenerateDesc, + onOrganizeMark, + onReconvertStt, + isReconvertSttLoading = false, onShowInFolder, onShowInFile, onRestore, @@ -105,6 +111,22 @@ export function MarkMobileActions({ {t('record.mark.toolbar.regenerateDesc')} + + onOrganizeMark(e)} + > + + {t('record.mark.toolbar.organizeThisRecording')} + + + onReconvertStt(e)} + > + + {isReconvertSttLoading ? t('record.mark.toolbar.reconvertSttProcessing') : t('record.mark.toolbar.reconvertStt')} + diff --git a/src/app/core/main/mark/organize-notes.tsx b/src/app/core/main/mark/organize-notes.tsx index 54fe487ec..6611b35e1 100644 --- a/src/app/core/main/mark/organize-notes.tsx +++ b/src/app/core/main/mark/organize-notes.tsx @@ -1,4 +1,5 @@ "use client" +import { Mark } from "@/db/marks" import useSettingStore, { GenTemplate, GenTemplateRange } from "@/stores/setting" import useMarkStore from "@/stores/mark" import useArticleStore from "@/stores/article" @@ -41,15 +42,21 @@ interface OrganizeNotesProps { inputValue?: string; } -export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNotesProps>(({ inputValue }, ref) => { +export interface OrganizeNotesHandle { + openOrganize: () => void; + openOrganizeWithMarks: (marks: Mark[]) => void; +} + +export const OrganizeNotes = forwardRef(({ inputValue }, ref) => { const [open, setOpen] = useState(false) + const [selectedMarkIds, setSelectedMarkIds] = useState(null) const { primaryModel } = useSettingStore() const { fetchMarks, marks } = useMarkStore() const { currentTag } = useTagStore() const { setActiveFilePath, loadFileTree, readArticle, setCurrentArticle, setSkipSyncOnSave, setAiGeneratingFilePath, setAiTerminateFn } = useArticleStore() const { setLeftSidebarTab } = useSidebarStore() const router = useRouter() - const [tab, setTab] = useState('0') + const [tab, setTab] = useState('') const [genTemplate, setGenTemplate] = useState([]) const [loading, setLoading] = useState(false) const abortControllerRef = useRef(null) @@ -58,7 +65,12 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo const t = useTranslations('record.chat.note') const tMark = useTranslations('record.mark') - async function initGenTemplates() { + const noteText = useCallback((key: string, fallback: string) => { + const translated = t(key) + return translated === `record.chat.note.${key}` ? fallback : translated + }, [t]) + + const initGenTemplates = useCallback(async () => { const store = await Store.load('store.json') const template = await store.get('templateList') || [] setGenTemplate(template) @@ -100,22 +112,35 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo return marks.filter(item => dayjs(item.createdAt).isAfter(subtractDate)) }, [marks, genTemplate, tab]) - // 使用 useMemo 优化分类记录 - const categorizedMarks = useMemo(() => { - return { - scanMarks: marksByRange.filter(item => item.type === 'scan'), - textMarks: marksByRange.filter(item => item.type === 'text'), - imageMarks: marksByRange.filter(item => item.type === 'image'), - linkMarks: marksByRange.filter(item => item.type === 'link'), - fileMarks: marksByRange.filter(item => item.type === 'file') + const organizeSourceMarks = useMemo(() => { + if (!selectedMarkIds) { + return marksByRange } - }, [marksByRange]) + + return marks.filter(item => selectedMarkIds.includes(item.id)) + }, [marks, marksByRange, selectedMarkIds]) // 使用 useMemo 优化选中的模板 const selectedTemplate = useMemo(() => { return genTemplate.find(item => item.id === tab) }, [genTemplate, tab]) + const isScopedSelection = (selectedMarkIds?.length || 0) > 0 + const isSingleSelection = (selectedMarkIds?.length || 0) === 1 + + useEffect(() => { + if (genTemplate.length === 0) { + if (tab !== '') { + setTab('') + } + return + } + + if (!genTemplate.some(item => item.id === tab)) { + setTab(genTemplate[0].id) + } + }, [genTemplate, tab]) + const terminateGeneration = useCallback(() => { if (abortControllerRef.current) { abortControllerRef.current.abort() @@ -125,6 +150,7 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo }, []) const openOrganize = useCallback(() => { + setSelectedMarkIds(null) setOpen(true) void initGenTemplates() }, []) @@ -155,12 +181,19 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo await writeTextFile(pathOptions.path, '', { baseDir: pathOptions.baseDir }) } + const sidebarState = useSidebarStore.getState() + + if (!sidebarState.leftSidebarVisible) { + await sidebarState.toggleLeftSidebar() + } + if (!sidebarState.centerPanelVisible) { + await sidebarState.toggleCenterPanel() + } + await setLeftSidebarTab('notes') + await loadFileTree() await setActiveFilePath(filePath) - // Switch to files tab in sidebar - await setLeftSidebarTab('files') - await new Promise(resolve => setTimeout(resolve, 500)) await fetchMarks() @@ -194,12 +227,15 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo subtractDate = dayjs().subtract(99, 'year') break } - const marksByRange = latestMarks.filter(item => dayjs(item.createdAt).isAfter(subtractDate)) + const marksByRange = selectedMarkIds + ? latestMarks.filter(item => selectedMarkIds.includes(item.id)) + : latestMarks.filter(item => dayjs(item.createdAt).isAfter(subtractDate)) // Calculate categorizedMarks with latest marks const categorizedMarks = { scanMarks: marksByRange.filter(item => item.type === 'scan'), textMarks: marksByRange.filter(item => item.type === 'text'), + recordingMarks: marksByRange.filter(item => item.type === 'recording'), imageMarks: marksByRange.filter(item => item.type === 'image'), linkMarks: marksByRange.filter(item => item.type === 'link'), fileMarks: marksByRange.filter(item => item.type === 'file') @@ -223,6 +259,8 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo ${categorizedMarks.scanMarks.map((item, index) => `Record ${index + 1}: ${item.content}. Created at ${dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}`).join(';\n\n')}. Here are text fragments copied and recorded: ${categorizedMarks.textMarks.map((item, index) => `Record ${index + 1}: ${item.content}. Created at ${dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}`).join(';\n\n')}. + Here are speech-to-text transcripts from audio recordings: + ${categorizedMarks.recordingMarks.map((item, index) => `Recording ${index + 1}: ${item.content}. Created at ${dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}`).join(';\n\n')}. Here are image record descriptions: ${processedImageMarks.map(item => ` Description: ${item.content}, @@ -297,7 +335,10 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo fullContent = content // Update editor content in real-time without reloading file setCurrentArticle(content) - emitter.emit('external-content-update', content) + emitter.emit('external-content-update', { + content, + targetFilePath, + }) // Also write to file if (workspace.isCustom) { await writeTextFile(pathOptions.path, content) @@ -415,8 +456,25 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo }, [primaryModel, categorizedMarks, selectedTemplate, inputValue, fetchMarks, loadFileTree, setActiveFilePath, setLeftSidebarTab, setCurrentArticle, readArticle, tMark, loading]) useImperativeHandle(ref, () => ({ - openOrganize - })) + openOrganize, + openOrganizeWithMarks + }), [openOrganize, openOrganizeWithMarks]) + + useEffect(() => { + const handleOpenOrganize = (payload?: { marks?: Mark[] }) => { + if (payload?.marks?.length) { + openOrganizeWithMarks(payload.marks) + return + } + + openOrganize() + } + + emitter.on('open-organize-notes', handleOpenOrganize) + return () => { + emitter.off('open-organize-notes', handleOpenOrganize) + } + }, [openOrganize, openOrganizeWithMarks]) // Listen for abort event from editor useEffect(() => { @@ -449,7 +507,16 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo }, [router]) return ( - + { + if (open === nextOpen) { + return + } + + setOpen(nextOpen) + if (!nextOpen) { + setSelectedMarkIds(null) + } + }} open={open}> {t('organizeAs')} @@ -466,10 +533,16 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo
- +
- - + + {!isScopedSelection ? : null}
@@ -480,13 +553,15 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo
setIsRemoveThinking(checked === true)} /> - +
- - - + + +
diff --git a/src/app/core/main/mark/tag-manage.tsx b/src/app/core/main/mark/tag-manage.tsx index b71c972de..201d28cbe 100644 --- a/src/app/core/main/mark/tag-manage.tsx +++ b/src/app/core/main/mark/tag-manage.tsx @@ -373,10 +373,20 @@ export function TagManage() { }, [highlightedMarkId, setHighlightedMarkId]) React.useEffect(() => { - setVisibleMarkIds(visibleMarkIds) - return () => setVisibleMarkIds([]) + const currentVisibleMarkIds = useMarkStore.getState().visibleMarkIds + const hasSameLength = currentVisibleMarkIds.length === visibleMarkIds.length + const hasSameValues = hasSameLength && currentVisibleMarkIds.every((id, index) => id === visibleMarkIds[index]) + + if (!hasSameValues) { + setVisibleMarkIds(visibleMarkIds) + } + }, [setVisibleMarkIds, visibleMarkIds]) + React.useEffect(() => { + return () => setVisibleMarkIds([]) + }, [setVisibleMarkIds]) + const renderTagRecords = React.useCallback((tagId: number) => { const filteredMarks = getFilteredTagMarks(tagId).filter((mark: Mark) => { if (mark.type === 'image' || mark.type === 'scan') { diff --git a/src/app/core/main/page.tsx b/src/app/core/main/page.tsx index e6738cde6..75210fb66 100644 --- a/src/app/core/main/page.tsx +++ b/src/app/core/main/page.tsx @@ -6,7 +6,7 @@ import { EditorLayout } from './editor/editor-layout' import Chat from './chat' import dynamic from 'next/dynamic' import { useSidebarStore } from "@/stores/sidebar" -import { useEffect, useState, useRef } from 'react' +import { useCallback, useEffect, useState, useRef } from 'react' import { Store } from '@tauri-apps/plugin-store' import { ImperativePanelHandle } from 'react-resizable-panels' import { invoke } from "@tauri-apps/api/core" @@ -90,12 +90,12 @@ function ResizableWrapper() { // 初始化侧边栏状态 useEffect(() => { - initSidebarState() + void initSidebarState() calculateMinSizes() window.addEventListener('resize', calculateMinSizes) return () => window.removeEventListener('resize', calculateMinSizes) - }, []) + }, [initSidebarState]) // 当面板可见性变化时,控制面板的折叠和展开 useEffect(() => { @@ -148,11 +148,14 @@ function ResizableWrapper() { const actualLayout = getActualLayout() - const onLayout = (sizes: number[]) => { + const onLayout = useCallback((sizes: number[]) => { // 保存当前面板布局 const storageKey = `react-resizable-panels:main-layout:${layoutKey}` - localStorage.setItem(storageKey, JSON.stringify(sizes)); - }; + const nextLayout = JSON.stringify(sizes) + if (localStorage.getItem(storageKey) !== nextLayout) { + localStorage.setItem(storageKey, nextLayout) + } + }, [layoutKey]) // 根据可见面板数量动态构建布局 const renderLayout = () => { diff --git a/src/app/mobile/layout.tsx b/src/app/mobile/layout.tsx index 0e1e0db0d..caf2dc194 100644 --- a/src/app/mobile/layout.tsx +++ b/src/app/mobile/layout.tsx @@ -35,20 +35,34 @@ export default function RootLayout({ const { initSettingData, customThemeColors } = useSettingStore() const { initMainHosting } = useImageStore() const { currentLocale } = useI18n() - useEffect(() => { - initSettingData() - initMainHosting() - initAllDatabases() - initMcp() - // 上报应用启动事件 - reportAppStart() - }, []) - const { initVectorDb } = useVectorStore() - - // 初始化向量数据库 + useEffect(() => { - initVectorDb() + let cancelled = false + + const initializeApp = async () => { + try { + initSettingData() + initMainHosting() + + await initAllDatabases() + if (cancelled) return + + await initVectorDb() + if (cancelled) return + + initMcp() + reportAppStart() + } catch (error) { + console.error('Failed to initialize mobile app core:', error) + } + } + + void initializeApp() + + return () => { + cancelled = true + } }, []) useEffect(() => { diff --git a/src/components/sync-confirm-dialog.tsx b/src/components/sync-confirm-dialog.tsx index 12948452e..7afd3ef1a 100644 --- a/src/components/sync-confirm-dialog.tsx +++ b/src/components/sync-confirm-dialog.tsx @@ -33,12 +33,14 @@ import { cn } from '@/lib/utils' import emitter from '@/lib/emitter' import { getSyncPushQueue } from '@/lib/sync/sync-push-queue' import { useEffect } from 'react' +import { useTranslations } from 'next-intl' // 初始化 dayjs 插件 dayjs.extend(relativeTime) export function SyncConfirmDialog() { const { currentLocale } = useI18n() + const t = useTranslations('article.syncConfirm') const isMobile = useIsMobile() || checkIsMobileDevice() const { isOpen, @@ -146,7 +148,7 @@ export function SyncConfirmDialog() { {commitInfo && (
-

最新提交信息

+

{t('commitInfo')}

{commitInfo.sha.slice(0, 7)} @@ -154,7 +156,7 @@ export function SyncConfirmDialog() {
-

提交消息

+

{t('commitMessage')}

{commitInfo.message}

@@ -221,16 +223,16 @@ export function SyncConfirmDialog() {
{commitInfo && (
-
-

远程版本信息

- - {commitInfo.sha.slice(0, 7)} - +
+

{t('commitInfo')}

+ + {commitInfo.sha.slice(0, 7)} +
-

提交消息

+

{t('commitMessage')}

{commitInfo.message}

@@ -346,7 +348,7 @@ export function SyncConfirmDialog() { {commitInfo && (
-

最新提交信息

+

{t('commitInfo')}

{commitInfo.sha.slice(0, 7)} @@ -354,7 +356,7 @@ export function SyncConfirmDialog() {
-

提交消息

+

{t('commitMessage')}

{commitInfo.message}

@@ -421,16 +423,16 @@ export function SyncConfirmDialog() {
{commitInfo && (
-
-

远程版本信息

- - {commitInfo.sha.slice(0, 7)} - +
+

{t('commitInfo')}

+ + {commitInfo.sha.slice(0, 7)} +
-

提交消息

+

{t('commitMessage')}

{commitInfo.message}

diff --git a/src/db/index.ts b/src/db/index.ts index 6f5d68b72..9bac47196 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,12 +1,15 @@ import Database from '@tauri-apps/plugin-sql'; -// 导出数据库实例 -export const db = await Database.load('sqlite:note.db'); +let dbPromise: Promise | null = null; // 获取数据库实例(兼容旧代码) export async function getDb() { - return db; + if (!dbPromise) { + dbPromise = Database.load('sqlite:note.db'); + } + + return dbPromise; } // 初始化所有数据库 diff --git a/src/db/vector.ts b/src/db/vector.ts index 67b14681f..6b67908a8 100644 --- a/src/db/vector.ts +++ b/src/db/vector.ts @@ -1,4 +1,4 @@ -import { db } from './index'; +import { getDb } from './index'; // 向量数据库表结构定义 export interface VectorDocument { @@ -44,6 +44,7 @@ class VectorCache { // 更新缓存 async update() { + const db = await getDb(); const docs = await db.select(` select id, filename, content, embedding, updated_at from vector_documents `); @@ -123,6 +124,7 @@ const vectorCache = new VectorCache(); // 初始化向量数据库表 export async function initVectorDb() { + const db = await getDb(); await db.execute(` create table if not exists vector_documents ( id integer primary key autoincrement, @@ -147,6 +149,7 @@ export async function initVectorDb() { // 插入或更新向量文档 export async function upsertVectorDocument(doc: Omit) { + const db = await getDb(); await db.execute( "insert into vector_documents (filename, chunk_id, content, embedding, updated_at) values ($1, $2, $3, $4, $5) on conflict(filename, chunk_id) do update set content = excluded.content, embedding = excluded.embedding, updated_at = excluded.updated_at", [doc.filename, doc.chunk_id, doc.content, doc.embedding, doc.updated_at]); @@ -164,6 +167,7 @@ export async function upsertVectorDocument(doc: Omit) { // 获取指定文件名的所有向量文档 export async function getVectorDocumentsByFilename(filename: string) { + const db = await getDb(); return await db.select( "select * from vector_documents where filename = $1 order by chunk_id", [filename]); @@ -171,6 +175,7 @@ export async function getVectorDocumentsByFilename(filename: string) { // 通过文件名删除向量文档 export async function deleteVectorDocumentsByFilename(filename: string) { + const db = await getDb(); await db.execute( "delete from vector_documents where filename = $1", [filename]); @@ -181,6 +186,7 @@ export async function deleteVectorDocumentsByFilename(filename: string) { // 检查文件是否已存在于向量数据库中 export async function checkVectorDocumentExists(filename: string) { + const db = await getDb(); const result = await db.select<{ count: number }[]>( "select count(*) as count from vector_documents where filename = $1", [filename]); @@ -247,6 +253,7 @@ function cosineSimilarity(vecA: number[], vecB: number[]): number { // 清空向量数据库 export async function clearVectorDb() { + const db = await getDb(); await db.execute(` delete from vector_documents `); @@ -257,6 +264,7 @@ export async function clearVectorDb() { // 获取所有向量文档的文件名列表 export async function getAllVectorDocumentFilenames() { + const db = await getDb(); return await db.select<{filename: string}[]>(` select distinct filename from vector_documents `); diff --git a/src/lib/agent/tools/note-tools.ts b/src/lib/agent/tools/note-tools.ts index b8ff3606b..252f505da 100644 --- a/src/lib/agent/tools/note-tools.ts +++ b/src/lib/agent/tools/note-tools.ts @@ -430,7 +430,10 @@ export const updateMarkdownFileTool: Tool = { const articleStore = useArticleStore.getState() if (articleStore.activeFilePath === normalizedFilePath) { // 使用 emitter 通知编辑器内容已从外部更新 - emitter.emit('external-content-update', params.content) + emitter.emit('external-content-update', { + content: params.content, + targetFilePath: params.filePath, + }) } const updatedStat = baseDir diff --git a/src/lib/emitter.ts b/src/lib/emitter.ts index 535334395..5cafb7c16 100644 --- a/src/lib/emitter.ts +++ b/src/lib/emitter.ts @@ -1,6 +1,7 @@ import mitt from 'mitt' import type { QuickPrompt } from '@/lib/ai/placeholder' import type { OnboardingStepId } from '@/app/core/main/editor/onboarding-state' +import type { Mark } from '@/db/marks' // 定义事件类型 interface Events { @@ -10,7 +11,7 @@ interface Events { 'editor-input': unknown; 'editor:ready': unknown; 'editor-mode-changed': string; - 'external-content-update': string; + 'external-content-update': string | { content: string; targetFilePath?: string }; 'editor-content-from-remote': { content: string }; 'toolbar-text-number': number; 'toolbar-reset-selected-text': unknown; @@ -69,6 +70,7 @@ interface Events { 'toolbar-question': unknown; 'toolbar-translation': unknown; 'toolbar-organize': unknown; + 'open-organize-notes': { marks?: Mark[] } | undefined; 'screenshot-shortcut-register': unknown; 'text-shortcut-register': unknown; 'window-pin-register': unknown; diff --git a/src/stores/article.ts b/src/stores/article.ts index 15e061314..ffb38083a 100644 --- a/src/stores/article.ts +++ b/src/stores/article.ts @@ -445,6 +445,11 @@ const useArticleStore = create((set, get) => ({ activeFilePath: '', setActiveFilePath: async (path: string) => { + const state = get() + if (state.activeFilePath === path && (state.currentArticle !== '' || state.readFilePath === path)) { + return + } + // 切换文件时,先清空 currentArticle,避免内容覆盖 set({ currentArticle: '', activeFilePath: path }) const store = await getStore(); @@ -1960,7 +1965,13 @@ const useArticleStore = create((set, get) => ({ vectorIndexedFiles: new Map(), // 文件名 -> 向量索引时间戳 setCurrentArticle: (content: string) => { - set({ currentArticle: content }) + set((state) => { + if (state.currentArticle === content) { + return state + } + + return { currentArticle: content } + }) }, setIsPulling: (pulling: boolean) => { @@ -1972,15 +1983,33 @@ const useArticleStore = create((set, get) => ({ }, setSkipSyncOnSave: (skip: boolean) => { - set({ skipSyncOnSave: skip }) + set((state) => { + if (state.skipSyncOnSave === skip) { + return state + } + + return { skipSyncOnSave: skip } + }) }, setAiGeneratingFilePath: (path: string | null) => { - set({ aiGeneratingFilePath: path }) + set((state) => { + if (state.aiGeneratingFilePath === path) { + return state + } + + return { aiGeneratingFilePath: path } + }) }, setAiTerminateFn: (fn: (() => void) | null) => { - set({ aiTerminateFn: fn }) + set((state) => { + if (state.aiTerminateFn === fn) { + return state + } + + return { aiTerminateFn: fn } + }) }, // 更新文件 sha 状态(推送成功后调用) diff --git a/src/stores/mark.ts b/src/stores/mark.ts index f4e149f75..581eaf03c 100644 --- a/src/stores/mark.ts +++ b/src/stores/mark.ts @@ -39,6 +39,23 @@ const DEFAULT_RECORD_FILTERS: RecordFilters = { tagId: 'all', } +function areArraysEqual(left: T[], right: T[]) { + if (left.length !== right.length) { + return false + } + + return left.every((value, index) => value === right[index]) +} + +function areRecordFiltersEqual(left: RecordFilters, right: RecordFilters) { + return ( + left.search === right.search && + left.timePreset === right.timePreset && + left.tagId === right.tagId && + areArraysEqual(left.selectedTypes, right.selectedTypes) + ) +} + async function persistRecordFilters(recordFilters: RecordFilters) { const store = await Store.load('store.json') await store.set('recordFilters', recordFilters) @@ -240,15 +257,33 @@ const useMarkStore = create((set, get) => ({ }, visibleMarkIds: [], setVisibleMarkIds: (ids) => { - set({ visibleMarkIds: ids }) + set((state) => { + if (areArraysEqual(state.visibleMarkIds, ids)) { + return state + } + + return { visibleMarkIds: ids } + }) }, pendingScrollMarkId: null, setPendingScrollMarkId: (id) => { - set({ pendingScrollMarkId: id }) + set((state) => { + if (state.pendingScrollMarkId === id) { + return state + } + + return { pendingScrollMarkId: id } + }) }, highlightedMarkId: null, setHighlightedMarkId: (id) => { - set({ highlightedMarkId: id }) + set((state) => { + if (state.highlightedMarkId === id) { + return state + } + + return { highlightedMarkId: id } + }) }, recordFilters: DEFAULT_RECORD_FILTERS, @@ -317,14 +352,25 @@ const useMarkStore = create((set, get) => ({ initRecordFilters: async () => { const store = await Store.load('store.json') const savedFilters = await store.get('recordFilters') - set({ - recordFilters: normalizeRecordFilters(savedFilters), + const normalizedFilters = normalizeRecordFilters(savedFilters) + set((state) => { + if (areRecordFiltersEqual(state.recordFilters, normalizedFilters)) { + return state + } + + return { + recordFilters: normalizedFilters, + } }) }, recordViewMode: 'list', setRecordViewMode: (mode) => { const recordViewMode = normalizeRecordViewMode(mode) as RecordViewMode + if (get().recordViewMode === recordViewMode) { + return + } + void persistRecordViewMode(recordViewMode) set({ recordViewMode }) }, @@ -335,7 +381,13 @@ const useMarkStore = create((set, get) => ({ if (savedRecordViewMode !== recordViewMode) { await store.set('recordViewMode', recordViewMode) } - set({ recordViewMode }) + set((state) => { + if (state.recordViewMode === recordViewMode) { + return state + } + + return { recordViewMode } + }) }, // 同步 diff --git a/src/stores/sidebar.ts b/src/stores/sidebar.ts index ec1d83e2c..1ce3ad4d8 100644 --- a/src/stores/sidebar.ts +++ b/src/stores/sidebar.ts @@ -137,6 +137,10 @@ export const useSidebarStore = create((set, get) => ({ }, leftSidebarTab: 'files', setLeftSidebarTab: async (tab: 'files' | 'notes') => { + if (get().leftSidebarTab === tab) { + return + } + set({ leftSidebarTab: tab }) localStorage.setItem('leftSidebarTab', tab) const store = await Store.load('store.json') @@ -149,20 +153,21 @@ export const useSidebarStore = create((set, get) => ({ const centerState = await store.get('centerPanelVisible') const rightState = await store.get('rightSidebarVisible') const leftTab = await store.get<'files' | 'notes'>('leftSidebarTab') + const currentState = get() - if (leftState !== null && leftState !== undefined) { + if (leftState !== null && leftState !== undefined && currentState.leftSidebarVisible !== leftState) { set({ leftSidebarVisible: leftState }) localStorage.setItem('leftSidebarVisible', String(leftState)) } - if (centerState !== null && centerState !== undefined) { + if (centerState !== null && centerState !== undefined && currentState.centerPanelVisible !== centerState) { set({ centerPanelVisible: centerState }) localStorage.setItem('centerPanelVisible', String(centerState)) } - if (rightState !== null && rightState !== undefined) { + if (rightState !== null && rightState !== undefined && currentState.rightSidebarVisible !== rightState) { set({ rightSidebarVisible: rightState }) localStorage.setItem('rightSidebarVisible', String(rightState)) } - if (leftTab) { + if (leftTab && currentState.leftSidebarTab !== leftTab) { set({ leftSidebarTab: leftTab }) localStorage.setItem('leftSidebarTab', leftTab) } diff --git a/src/stores/tag.ts b/src/stores/tag.ts index 5db34ec06..2380b4c8f 100644 --- a/src/stores/tag.ts +++ b/src/stores/tag.ts @@ -11,6 +11,37 @@ import { Store } from '@tauri-apps/plugin-store' import { create } from 'zustand' import { S3Config, WebDAVConfig } from '@/types/sync' +function isSameTag(left?: Tag, right?: Tag) { + if (!left && !right) { + return true + } + + if (!left || !right) { + return false + } + + return ( + left.id === right.id && + left.name === right.name && + left.isLocked === right.isLocked && + left.isPin === right.isPin && + left.sortOrder === right.sortOrder && + left.total === right.total + ) +} + +function areTagsEqual(left: Tag[], right: Tag[]) { + if (left.length !== right.length) { + return false + } + + return left.every((tag, index) => isSameTag(tag, right[index])) +} + +function resolveCurrentTag(tags: Tag[], currentTagId: number) { + return tags.find((tag) => tag.id === currentTagId) +} + interface TagState { currentTagId: number setCurrentTagId: (id: number) => Promise @@ -37,38 +68,85 @@ const useTagStore = create((set, get) => ({ // 当前选择的 tag currentTagId: 1, setCurrentTagId: async(currentTagId: number) => { - set({ currentTagId }) + const nextCurrentTag = resolveCurrentTag(get().tags, currentTagId) + + set((state) => { + if (state.currentTagId === currentTagId && isSameTag(state.currentTag, nextCurrentTag)) { + return state + } + + return { + currentTagId, + currentTag: nextCurrentTag, + } + }) + const store = await Store.load('store.json'); await store.set('currentTagId', currentTagId) }, initTags: async () => { const store = await Store.load('store.json'); const currentTagId = await store.get('currentTagId') - if (currentTagId) set({ currentTagId }) - get().getCurrentTag() + + if (!currentTagId) { + get().getCurrentTag() + return + } + + set((state) => { + const nextCurrentTag = resolveCurrentTag(state.tags, currentTagId) + + if (state.currentTagId === currentTagId && isSameTag(state.currentTag, nextCurrentTag)) { + return state + } + + return { + currentTagId, + currentTag: nextCurrentTag, + } + }) }, currentTag: undefined, getCurrentTag: () => { - const tags = get().tags - const getcurrentTagId = get().currentTagId - const currentTag = tags.find((tag) => tag.id === getcurrentTagId) - if (currentTag) { - set({ currentTag }) - } + const { tags, currentTagId } = get() + const currentTag = resolveCurrentTag(tags, currentTagId) + + set((state) => { + if (isSameTag(state.currentTag, currentTag)) { + return state + } + + return { currentTag } + }) }, // 所有 tag tags: [], fetchTags: async () => { const tags = await getTags() - set({ tags }) + set((state) => { + const currentTag = resolveCurrentTag(tags, state.currentTagId) + + if (areTagsEqual(state.tags, tags) && isSameTag(state.currentTag, currentTag)) { + return state + } + + return { + tags, + currentTag, + } + }) }, deleteTag: async (id: number) => { await delTag(id) await get().fetchTags() - await get().setCurrentTagId(get().tags[0].id) + + const nextTag = get().tags[0] + if (nextTag) { + await get().setCurrentTagId(nextTag.id) + } }, // 同步