From 7bb1319f84425a1a49a75e0a085019c8e21d5e51 Mon Sep 17 00:00:00 2001 From: codexu <461229187@qq.com> Date: Mon, 2 Mar 2026 17:17:17 +0800 Subject: [PATCH 01/11] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=BA=93=E6=80=BB=E8=A7=88=E4=B8=8E=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=9B=BE=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CODEBASE-OVERVIEW.md | 97 +++++++++++++++++++++++++ docs/MODULE-MAP.md | 72 +++++++++++++++++++ docs/READING-ORDER.md | 51 +++++++++++++ docs/SYSTEM-DIAGRAM-TEXT.md | 61 ++++++++++++++++ docs/SYSTEM-DIAGRAM.html | 140 ++++++++++++++++++++++++++++++++++++ 5 files changed, 421 insertions(+) create mode 100644 docs/CODEBASE-OVERVIEW.md create mode 100644 docs/MODULE-MAP.md create mode 100644 docs/READING-ORDER.md create mode 100644 docs/SYSTEM-DIAGRAM-TEXT.md create mode 100644 docs/SYSTEM-DIAGRAM.html 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
  • +
+
+
+
+ + From ad8a76b43b8fa4ba87ab847de534c256a04eda77 Mon Sep 17 00:00:00 2001 From: codexu <461229187@qq.com> Date: Mon, 2 Mar 2026 17:17:23 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=E5=BD=95=E9=9F=B3=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=E6=94=AF=E6=8C=81=E9=87=8D=E6=96=B0=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E5=B9=B6=E6=98=BE=E7=A4=BA=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en.json | 2 + messages/ja.json | 2 + messages/pt-BR.json | 2 + messages/zh-TW.json | 2 + messages/zh.json | 2 + src/app/core/main/mark/mark-item.tsx | 104 +++++++++++++++++- .../core/main/mark/mark-mobile-actions.tsx | 14 ++- 7 files changed, 124 insertions(+), 4 deletions(-) diff --git a/messages/en.json b/messages/en.json index bf7b89e14..9431d2f6b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1523,6 +1523,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", diff --git a/messages/ja.json b/messages/ja.json index 3e5f0fd94..3cf22edbe 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -1538,6 +1538,8 @@ "copyLink": "リンクをコピー", "copied": "クリップボードにコピーしました!", "regenerateDesc": "説明を再生成", + "reconvertStt": "音声を再認識", + "reconvertSttProcessing": "音声を再認識中...", "viewFolder": "フォルダで表示", "viewFile": "元ファイルを表示", "deleteForever": "完全に削除", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index e9e9c4414..04d9b62b3 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -1561,6 +1561,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", diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 3be6b8359..4f198283c 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -1538,6 +1538,8 @@ "copyLink": "複製連結", "copied": "已複製到剪切板!", "regenerateDesc": "重新生成描述", + "reconvertStt": "重新語音識別", + "reconvertSttProcessing": "重新語音識別中...", "viewFolder": "查看目錄", "viewFile": "查看原文件", "deleteForever": "徹底刪除", diff --git a/messages/zh.json b/messages/zh.json index 89b18e96c..52294bacf 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -1506,6 +1506,8 @@ "copyLink": "复制链接", "copied": "已复制到剪切板!", "regenerateDesc": "重新生成描述", + "reconvertStt": "重新语音识别", + "reconvertSttProcessing": "重新语音识别中...", "viewFolder": "查看目录", "viewFile": "查看原文件", "deleteForever": "彻底删除", diff --git a/src/app/core/main/mark/mark-item.tsx b/src/app/core/main/mark/mark-item.tsx index 2f4ea70c2..530900ff2 100644 --- a/src/app/core/main/mark/mark-item.tsx +++ b/src/app/core/main/mark/mark-item.tsx @@ -21,7 +21,7 @@ import { LocalImage } from "@/components/local-image"; import { fetchAiDesc } from "@/lib/ai/description"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; import { appDataDir } from "@tauri-apps/api/path"; -import { ImageUp } from "lucide-react"; +import { ImageUp, LoaderCircle } from "lucide-react"; import { toast } from "@/hooks/use-toast"; import { open } from "@tauri-apps/plugin-shell"; import { Textarea } from "@/components/ui/textarea"; @@ -33,6 +33,8 @@ import { MarkMobileActions } from "./mark-mobile-actions"; import { markToMarkdown } from "@/lib/mark-to-markdown"; import useSettingStore from "@/stores/setting"; import { TodoItemContent } from "./todo-item-content"; +import { fetchAudioTranscription } from "@/lib/audio"; +import { BaseDirectory, readFile } from "@tauri-apps/plugin-fs"; dayjs.extend(relativeTime) @@ -139,7 +141,7 @@ DetailViewer.displayName = 'DetailViewer' export const MarkWrapper = React.memo(({mark}: {mark: Mark}) => { const t = useTranslations('record.mark.type'); - const { isMultiSelectMode, selectedMarkIds, toggleMarkSelection } = useMarkStore(); + const { isMultiSelectMode, selectedMarkIds, toggleMarkSelection, queues } = useMarkStore(); const { recordTextSize } = useSettingStore(); const lineHeight = useMemo(() => getLineHeight(recordTextSize), [recordTextSize]) @@ -148,6 +150,14 @@ export const MarkWrapper = React.memo(({mark}: {mark: Mark}) => { toggleMarkSelection(mark.id); }, [mark.id, toggleMarkSelection]); + 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 renderContent = () => { switch (mark.type) { case 'scan': @@ -222,6 +232,12 @@ export const MarkWrapper = React.memo(({mark}: {mark: Mark}) => { {mark.url && (
+ {isReconvertingStt && ( +
+ + {t('record.mark.toolbar.reconvertSttProcessing')} +
+ )}
)} @@ -277,6 +293,7 @@ MarkWrapper.displayName = 'MarkWrapper' export const MarkItem = React.memo(({mark}: {mark: Mark}) => { const t = useTranslations(); + const { sttModel } = useSettingStore() const { marks, fetchMarks, @@ -284,9 +301,12 @@ export const MarkItem = React.memo(({mark}: {mark: Mark}) => { fetchAllTrashMarks, isMultiSelectMode, selectedMarkIds, - clearSelection + clearSelection, + addQueue, + removeQueue } = useMarkStore() const { tags, currentTagId, fetchTags, getCurrentTag } = useTagStore() + const [isReconvertSttLoading, setIsReconvertSttLoading] = useState(false) const handleDragStart = useCallback((e: React.DragEvent) => { if (isMultiSelectMode) { @@ -384,6 +404,74 @@ export const MarkItem = React.memo(({mark}: {mark: Mark}) => { 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 fetchAudioTranscription(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 handelShowInFolder = useCallback(async (e?: React.MouseEvent) => { e?.stopPropagation() const appDir = await appDataDir() @@ -438,6 +526,8 @@ export const MarkItem = React.memo(({mark}: {mark: Mark}) => { onTransfer={handleTransfer} onCopyLink={handleCopyLink} onRegenerateDesc={regenerateDesc} + onReconvertStt={handleReconvertStt} + isReconvertSttLoading={isReconvertSttLoading} onShowInFolder={handelShowInFolder} onShowInFile={handelShowInFile} onRestore={handleRestore} @@ -482,6 +572,14 @@ export const MarkItem = React.memo(({mark}: {mark: Mark}) => { {t('record.mark.toolbar.regenerateDesc')} + + {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-mobile-actions.tsx b/src/app/core/main/mark/mark-mobile-actions.tsx index 2d708e687..9d8523783 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 } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -27,6 +27,8 @@ interface MarkMobileActionsProps { onTransfer: (tagId: number, e?: React.MouseEvent) => void onCopyLink: (e?: React.MouseEvent) => void onRegenerateDesc: (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 +46,8 @@ export function MarkMobileActions({ onTransfer, onCopyLink, onRegenerateDesc, + onReconvertStt, + isReconvertSttLoading = false, onShowInFolder, onShowInFile, onRestore, @@ -105,6 +109,14 @@ export function MarkMobileActions({ {t('record.mark.toolbar.regenerateDesc')} + + onReconvertStt(e)} + > + + {isReconvertSttLoading ? t('record.mark.toolbar.reconvertSttProcessing') : t('record.mark.toolbar.reconvertStt')} + From 4186079aaa18f00ab74ca1e405e7e74dd51544b5 Mon Sep 17 00:00:00 2001 From: codexu <461229187@qq.com> Date: Wed, 4 Mar 2026 10:56:35 +0800 Subject: [PATCH 03/11] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=BD=95?= =?UTF-8?q?=E9=9F=B3=E9=87=8D=E8=AF=86=E5=88=AB=E7=8A=B6=E6=80=81=E6=96=87?= =?UTF-8?q?=E6=A1=88=E9=94=AE=E6=98=BE=E7=A4=BA=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/core/main/mark/mark-item.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/core/main/mark/mark-item.tsx b/src/app/core/main/mark/mark-item.tsx index 530900ff2..7c1885171 100644 --- a/src/app/core/main/mark/mark-item.tsx +++ b/src/app/core/main/mark/mark-item.tsx @@ -141,6 +141,7 @@ DetailViewer.displayName = 'DetailViewer' export const MarkWrapper = React.memo(({mark}: {mark: Mark}) => { const t = useTranslations('record.mark.type'); + const toolbarT = useTranslations('record.mark.toolbar'); const { isMultiSelectMode, selectedMarkIds, toggleMarkSelection, queues } = useMarkStore(); const { recordTextSize } = useSettingStore(); @@ -235,7 +236,7 @@ export const MarkWrapper = React.memo(({mark}: {mark: Mark}) => { {isReconvertingStt && (
- {t('record.mark.toolbar.reconvertSttProcessing')} + {toolbarT('reconvertSttProcessing')}
)} From 8f18064b332d957a0a1a17f6a1c4261699d8cc80 Mon Sep 17 00:00:00 2001 From: codexu <461229187@qq.com> Date: Mon, 30 Mar 2026 21:52:25 +0800 Subject: [PATCH 04/11] feat: improve recording organize flow and fix update loops --- messages/en.json | 24 +++ messages/ja.json | 24 +++ messages/pt-BR.json | 24 +++ messages/zh-TW.json | 33 ++++ messages/zh.json | 27 ++++ src/app/core/main/mark/mark-actions.tsx | 10 +- src/app/core/main/mark/mark-item.tsx | 64 ++++++-- src/app/core/main/mark/mark-list.tsx | 14 +- .../core/main/mark/mark-mobile-actions.tsx | 12 +- src/app/core/main/mark/organize-notes.tsx | 142 ++++++++++++++---- src/app/core/main/mark/tag-manage.tsx | 14 +- src/lib/emitter.ts | 2 + src/stores/article.ts | 37 ++++- src/stores/mark.ts | 72 ++++++++- src/stores/sidebar.ts | 4 + 15 files changed, 441 insertions(+), 62 deletions(-) diff --git a/messages/en.json b/messages/en.json index 33fd3a723..468c57185 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1543,6 +1543,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)", @@ -1648,6 +1658,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", @@ -1677,10 +1689,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" } @@ -1745,10 +1763,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 1bda3bf64..a77b15ddd 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -1555,6 +1555,16 @@ "convert": "文章に変換", "description": "現在のノートはAIによって生成されており、編集できません。現在のノートを記事に変換(ローカルファイルの生成)して、執筆ページで二次創作を行うことができます。", "filename": "ファイル名", + "organizeAllAs": "現在のタグを整理", + "organizeSingleAs": "この録音を整理", + "selectedRecording": "現在の範囲: 単一録音", + "currentScope": "現在の範囲", + "templateContent": "テンプレート内容", + "recordRange": "記録範囲を選択", + "filterThinkingContent": "思考の記録を削除します", + "startOrganizeAll": "現在のタグを整理", + "startOrganizeSingle": "この録音を整理", + "manageTemplate": "管理テンプレート", "selectFolder": "フォルダを選択", "rootDirectory": "ルートディレクトリ", "deleteTag": "現在のタグ、記録、ノートを削除(ゴミ箱から復元可能)", @@ -1626,6 +1636,8 @@ "deleteSelected": "選択した{count}項目を削除", "deleteSelectedForever": "選択した{count}項目を完全削除", "organizeNotes": "ノート整理", + "organizeCurrentTag": "現在のタグを整理", + "organizeThisRecording": "この録音を整理", "organizeSuccess": "ノート整理成功:{title}", "organizeError": "ノート整理失敗", "currentTag": "現在のタグ", @@ -1643,10 +1655,16 @@ }, "note": { "organizeAs": "整理先", + "organizeAllAs": "現在のタグを整理", + "organizeSingleAs": "この録音を整理", + "selectedRecording": "現在の範囲: 単一録音", + "currentScope": "現在の範囲", "template": "テンプレート", "setting": "設定", "confirm": "确认", "cancel": "取消", + "startOrganizeAll": "現在のタグを整理", + "startOrganizeSingle": "この録音を整理", "removeThinking": "移除思考过程", "stop": "停止" } @@ -1860,10 +1878,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 6736d7579..250da7142 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -1570,6 +1570,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)", @@ -1656,6 +1666,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", @@ -1673,10 +1685,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" }, @@ -1738,10 +1756,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 3ec3c70ed..d8f9268f5 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -1554,6 +1554,16 @@ "convert": "轉化文章", "description": "當前的筆記是由 AI 生成且無法編輯,將當前筆記轉化為文章(生成本地文件),可在寫作頁面中進行二次創作。", "filename": "檔案名", + "organizeAllAs": "整理目前標籤", + "organizeSingleAs": "整理這段錄音", + "selectedRecording": "目前範圍: 單條錄音", + "currentScope": "目前範圍", + "templateContent": "模板內容", + "recordRange": "記錄選擇範圍", + "filterThinkingContent": "移除記錄中的思考", + "startOrganizeAll": "開始整理目前標籤", + "startOrganizeSingle": "開始整理這段錄音", + "manageTemplate": "管理模板", "selectFolder": "選擇文件夾", "rootDirectory": "根目錄", "deleteTag": "刪除當前標籤、記錄和筆記(回收站可恢復)", @@ -1625,6 +1635,8 @@ "deleteSelected": "刪除選中的 {count} 項", "deleteSelectedForever": "徹底刪除選中的 {count} 項", "organizeNotes": "整理筆記", + "organizeCurrentTag": "整理目前標籤", + "organizeThisRecording": "整理這段錄音", "organizeSuccess": "筆記整理成功:{title}", "organizeError": "整理筆記失敗", "currentTag": "當前標籤", @@ -1640,6 +1652,21 @@ "list": { "title": "記錄" }, + "note": { + "organizeAs": "整理為", + "organizeAllAs": "整理目前標籤", + "organizeSingleAs": "整理這段錄音", + "selectedRecording": "目前範圍: 單條錄音", + "currentScope": "目前範圍", + "template": "模板", + "setting": "設定", + "confirm": "確認", + "cancel": "取消", + "startOrganizeAll": "開始整理目前標籤", + "startOrganizeSingle": "開始整理這段錄音", + "removeThinking": "移除思考過程", + "stop": "停止" + }, "imageGallery": { "expand": "展开", "collapse": "收起" @@ -1854,10 +1881,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 6bdac5960..003f0a84a 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -1521,6 +1521,16 @@ "convert": "转化文章", "description": "当前的笔记是由 AI 生成且无法编辑,将当前笔记转化为文章(生成本地文件),可在写作页面中进行二次创作。", "filename": "文件名", + "organizeAllAs": "整理当前标签", + "organizeSingleAs": "整理此录音", + "selectedRecording": "当前范围: 单条录音", + "currentScope": "当前范围", + "templateContent": "模板内容", + "recordRange": "记录选择范围", + "filterThinkingContent": "移除记录中的思考", + "startOrganizeAll": "开始整理当前标签", + "startOrganizeSingle": "开始整理此录音", + "manageTemplate": "管理模板", "selectFolder": "选择文件夹", "rootDirectory": "根目录", "deleteTag": "删除当前标签、记录和笔记(回收站可恢复)", @@ -1591,6 +1601,8 @@ "trash": "回收站", "closeTrash": "关闭回收站", "organizeNotes": "整理笔记", + "organizeCurrentTag": "整理当前标签", + "organizeThisRecording": "整理此录音", "organizeSuccess": "笔记整理成功:{title}", "organizeError": "整理笔记失败", "currentTag": "当前标签", @@ -1644,6 +1656,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/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 1f0e487e0..a9ebad8c2 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, LoaderCircle, RefreshCw, Settings2, Square } from "lucide-react"; +import { CheckSquare, ImageUp, LoaderCircle, RefreshCw, Settings2, Sparkles, Square } from "lucide-react"; import { toast } from "@/hooks/use-toast"; import { open } from "@tauri-apps/plugin-shell"; import { Textarea } from "@/components/ui/textarea"; @@ -40,6 +41,7 @@ import { NO_TRANSCRIPTION_MESSAGE, transcribeRecording } from "@/lib/audio"; import { getMarkTypeListBadgeClasses } from "./mark-type-meta"; import { getMarkListItemContent } from "./mark-list-item-content"; import { TodoEditTrigger } from "./todo-edit-button"; +import emitter from "@/lib/emitter"; dayjs.extend(relativeTime) @@ -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' @@ -338,14 +347,6 @@ export const MarkWrapper = React.memo(({mark, variant = 'list'}: {mark: Mark, va ) } - 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 renderContent = () => { switch (mark.type) { case 'scan': @@ -679,6 +680,16 @@ export const MarkItem = React.memo(({mark, variant = 'list'}: {mark: Mark, varia } }, [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() const appDir = await appDataDir() @@ -727,6 +738,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')} + buildRecordFilterSummary(recordFilters), [recordFilters]) 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 96f81f7dd..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, AudioLines } from 'lucide-react' +import { MoreVertical, FolderOpen, File, Link2, RefreshCw, Trash2, RotateCcw, XCircle, AudioLines, Sparkles } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -27,6 +27,7 @@ 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 @@ -46,6 +47,7 @@ export function MarkMobileActions({ onTransfer, onCopyLink, onRegenerateDesc, + onOrganizeMark, onReconvertStt, isReconvertSttLoading = false, onShowInFolder, @@ -110,6 +112,14 @@ export function MarkMobileActions({ {t('record.mark.toolbar.regenerateDesc')} + onOrganizeMark(e)} + > + + {t('record.mark.toolbar.organizeThisRecording')} + + onReconvertStt(e)} diff --git a/src/app/core/main/mark/organize-notes.tsx b/src/app/core/main/mark/organize-notes.tsx index fa9077cbd..03e7cd805 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" @@ -37,15 +38,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) @@ -53,11 +60,16 @@ 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) - } + }, []) // 使用 useMemo 优化过滤的记录 const marksByRange = useMemo(() => { @@ -89,22 +101,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() @@ -114,9 +139,16 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo }, []) const openOrganize = useCallback(() => { + setSelectedMarkIds(null) setOpen(true) - initGenTemplates() - }, []) + void initGenTemplates() + }, [initGenTemplates]) + + const openOrganizeWithMarks = useCallback((sourceMarks: Mark[]) => { + setSelectedMarkIds(sourceMarks.map(item => item.id)) + setOpen(true) + void initGenTemplates() + }, [initGenTemplates]) const handleOrganize = useCallback(async () => { setOpen(false) @@ -139,12 +171,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() @@ -178,12 +217,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') @@ -207,6 +249,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}, @@ -395,11 +439,28 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo targetFilePath: filePath }) } - }, [primaryModel, categorizedMarks, selectedTemplate, inputValue, fetchMarks, loadFileTree, setActiveFilePath, setLeftSidebarTab, setCurrentArticle, readArticle, tMark, t, open]) + }, [primaryModel, selectedTemplate, inputValue, fetchMarks, loadFileTree, setActiveFilePath, setLeftSidebarTab, setCurrentArticle, readArticle, tMark, t, selectedMarkIds, terminateGeneration, setSkipSyncOnSave, setAiGeneratingFilePath, setAiTerminateFn]) 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(() => { @@ -438,11 +499,20 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo }, [router]) return ( - + { + if (open === nextOpen) { + return + } + + setOpen(nextOpen) + if (!nextOpen) { + setSelectedMarkIds(null) + } + }} open={open}> - {t('organizeAs')} - setTab(value)}> + {isSingleSelection ? noteText('organizeSingleAs', '整理此录音') : noteText('organizeAllAs', '整理当前标签')} + setTab(value)}> { genTemplate.map(item => ( @@ -455,10 +525,16 @@ export const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNo
- +
- - + + {!isScopedSelection ? : null}
@@ -469,13 +545,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 24aca01ae..0a5c1e527 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/lib/emitter.ts b/src/lib/emitter.ts index cee01dc32..2bfe14011 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 { @@ -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 a935d4109..28fa6b02f 100644 --- a/src/stores/article.ts +++ b/src/stores/article.ts @@ -434,6 +434,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(); @@ -1887,7 +1892,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) => { @@ -1899,15 +1910,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 fe6c683c6..f025cdd7a 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) @@ -107,7 +124,13 @@ interface MarkState { const useMarkStore = create((set, get) => ({ trashState: false, setTrashState: (flag) => { - set({ trashState: flag }) + set((state) => { + if (state.trashState === flag) { + return state + } + + return { trashState: flag } + }) }, marks: [], @@ -233,15 +256,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, @@ -310,14 +351,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 }) }, @@ -328,7 +380,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..ff13408a8 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') From ad72139e41aebf570ee578d747e1bbf274f15000 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 01:51:02 +0000 Subject: [PATCH 05/11] Initial plan From feba128792e3ba25c8546e8f17c36f1ce3e8557f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 01:51:05 +0000 Subject: [PATCH 06/11] Initial plan From 16cf5a9276eae57e323012411c3dc65568be069f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 01:51:12 +0000 Subject: [PATCH 07/11] Initial plan From 46d08c6702334de688381165006afa18d445f0b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 02:00:09 +0000 Subject: [PATCH 08/11] Initial plan From 435a08f5697fa1a198925a9a3bdba50b8b985f83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 02:09:05 +0000 Subject: [PATCH 09/11] fix: localize commit and history labels Agent-Logs-Url: https://github.com/wlchen/note-gen/sessions/f0c03079-ab17-4a3c-811e-1f6e09113b03 Co-authored-by: wlchen <1633517+wlchen@users.noreply.github.com> --- .../editor/markdown/sync/history-sheet.tsx | 28 ++++++++------- src/components/sync-confirm-dialog.tsx | 34 ++++++++++--------- 2 files changed, 33 insertions(+), 29 deletions(-) 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 003ba4c63..ae59fbb05 100644 --- a/src/app/core/main/editor/markdown/sync/history-sheet.tsx +++ b/src/app/core/main/editor/markdown/sync/history-sheet.tsx @@ -15,6 +15,7 @@ import { saveLocalFile } from '@/lib/sync/auto-sync' import { updateFileSyncTime, updateFileRestoreTime } from '@/lib/sync/conflict-resolution' import { toast } from '@/hooks/use-toast' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { useTranslations } from 'next-intl' interface CommitInfo { sha: string @@ -32,6 +33,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([]) @@ -257,19 +259,19 @@ export function HistorySheet({ editor }: HistorySheetProps) { return ( - - + +
-
提交历史
+
{t('historyRecords')}
{activeFilePath && provider && repoInfo.repo && ( { @@ -294,11 +296,11 @@ export function HistorySheet({ editor }: HistorySheetProps) {
{isLoading ? (
- 加载中... + {t('loading')}
) : history.length === 0 ? (
- 暂无提交记录 + {t('noHistory')}
) : (
    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}

    From e76969a91c246e137db1fc1d19a67ad40adebc70 Mon Sep 17 00:00:00 2001 From: codexu <461229187@qq.com> Date: Wed, 1 Apr 2026 16:20:02 +0800 Subject: [PATCH 10/11] fix: prevent organize note render loops --- .../editor/markdown/md-editor-wrapper.tsx | 15 ++- .../main/editor/markdown/tiptap-editor.tsx | 21 +++- src/app/core/main/mark/organize-notes.tsx | 5 +- src/app/core/main/page.tsx | 15 +-- src/lib/agent/tools/note-tools.ts | 5 +- src/lib/emitter.ts | 2 +- src/stores/sidebar.ts | 9 +- src/stores/tag.ts | 100 ++++++++++++++++-- 8 files changed, 143 insertions(+), 29 deletions(-) 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 29d7d2a81..b2bd6d7e8 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/tiptap-editor.tsx b/src/app/core/main/editor/markdown/tiptap-editor.tsx index 50653b130..c941db301 100644 --- a/src/app/core/main/editor/markdown/tiptap-editor.tsx +++ b/src/app/core/main/editor/markdown/tiptap-editor.tsx @@ -1371,16 +1371,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 @@ -1638,7 +1644,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() @@ -1666,7 +1679,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/organize-notes.tsx b/src/app/core/main/mark/organize-notes.tsx index 03e7cd805..0118f57f4 100644 --- a/src/app/core/main/mark/organize-notes.tsx +++ b/src/app/core/main/mark/organize-notes.tsx @@ -325,7 +325,10 @@ export const OrganizeNotes = forwardRef 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) 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/lib/agent/tools/note-tools.ts b/src/lib/agent/tools/note-tools.ts index eeec6b2a0..9d3504031 100644 --- a/src/lib/agent/tools/note-tools.ts +++ b/src/lib/agent/tools/note-tools.ts @@ -329,7 +329,10 @@ export const updateMarkdownFileTool: Tool = { const articleStore = useArticleStore.getState() if (articleStore.activeFilePath === params.filePath) { // 使用 emitter 通知编辑器内容已从外部更新 - emitter.emit('external-content-update', params.content) + emitter.emit('external-content-update', { + content: params.content, + targetFilePath: params.filePath, + }) } return { diff --git a/src/lib/emitter.ts b/src/lib/emitter.ts index 2bfe14011..11c99dd0b 100644 --- a/src/lib/emitter.ts +++ b/src/lib/emitter.ts @@ -11,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; diff --git a/src/stores/sidebar.ts b/src/stores/sidebar.ts index ff13408a8..1ce3ad4d8 100644 --- a/src/stores/sidebar.ts +++ b/src/stores/sidebar.ts @@ -153,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) + } }, // 同步 From b1035b2723737bfa9a3dd2426ad91ed56e19d1bb Mon Sep 17 00:00:00 2001 From: wlchen Date: Wed, 1 Apr 2026 16:57:33 +0800 Subject: [PATCH 11/11] refactor: lazily initialize vector database --- src/app/mobile/layout.tsx | 38 ++++++++++++++++++++++++++------------ src/db/index.ts | 9 ++++++--- src/db/vector.ts | 10 +++++++++- 3 files changed, 41 insertions(+), 16 deletions(-) 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/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 `);