diff --git a/.github/workflows/build-and-notarize.yml b/.github/workflows/build-and-notarize.yml index 39b8e1ba7..d29829d52 100644 --- a/.github/workflows/build-and-notarize.yml +++ b/.github/workflows/build-and-notarize.yml @@ -54,6 +54,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + - name: Download Qdrant binary + run: node scripts/download-qdrant.js --current + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + - name: Cache Electron uses: actions/cache@v4 with: @@ -124,6 +129,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + - name: Download Qdrant binary + run: node scripts/download-qdrant.js --current + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + - name: Download nircmd.exe run: node scripts/download-nircmd.js env: @@ -132,12 +142,17 @@ jobs: - name: Download windows-fast-paste.exe run: node scripts/download-windows-fast-paste.js env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} - name: Download windows-key-listener.exe run: node scripts/download-windows-key-listener.js env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + + - name: Download windows-mic-listener.exe + run: node scripts/download-windows-mic-listener.js + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} - name: Cache Electron uses: actions/cache@v4 @@ -152,13 +167,16 @@ jobs: echo "GOOGLE_CALENDAR_CLIENT_ID=${{ secrets.GOOGLE_CALENDAR_CLIENT_ID }}" >> .env echo "GOOGLE_CALENDAR_CLIENT_SECRET=${{ secrets.GOOGLE_CALENDAR_CLIENT_SECRET }}" >> .env - - name: Build Application + - name: Build and Sign Application run: npm run build:win -- --publish never env: GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} VITE_NEON_AUTH_URL: ${{ vars.VITE_NEON_AUTH_URL }} VITE_OPENWHISPR_API_URL: ${{ vars.VITE_OPENWHISPR_API_URL }} VITE_OPENWHISPR_OAUTH_CALLBACK_URL: ${{ vars.VITE_OPENWHISPR_OAUTH_CALLBACK_URL }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - name: Upload Artifacts uses: actions/upload-artifact@v4 @@ -212,6 +230,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + - name: Download Qdrant binary + run: node scripts/download-qdrant.js --current --platform darwin --arch ${{ matrix.arch }} + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + - name: Cache Electron uses: actions/cache@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d691d9737..7b3cc63ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - name: Download Qdrant binary + run: node scripts/download-qdrant.js --current + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - name: Cache Electron uses: actions/cache@v4 with: @@ -116,6 +121,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - name: Download Qdrant binary + run: node scripts/download-qdrant.js --current + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - name: Download nircmd.exe run: node scripts/download-nircmd.js env: @@ -131,6 +141,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - name: Download windows-mic-listener.exe + run: node scripts/download-windows-mic-listener.js + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - name: Cache Electron uses: actions/cache@v4 with: @@ -144,13 +159,16 @@ jobs: echo "GOOGLE_CALENDAR_CLIENT_ID=${{ secrets.GOOGLE_CALENDAR_CLIENT_ID }}" >> .env echo "GOOGLE_CALENDAR_CLIENT_SECRET=${{ secrets.GOOGLE_CALENDAR_CLIENT_SECRET }}" >> .env - - name: Build Application + - name: Build and Sign Application run: npm run build:win -- --publish always env: GH_TOKEN: ${{ secrets.GH_TOKEN }} VITE_NEON_AUTH_URL: ${{ vars.VITE_NEON_AUTH_URL }} VITE_OPENWHISPR_API_URL: ${{ vars.VITE_OPENWHISPR_API_URL }} VITE_OPENWHISPR_OAUTH_CALLBACK_URL: ${{ vars.VITE_OPENWHISPR_OAUTH_CALLBACK_URL }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} build-macos: runs-on: macos-latest @@ -197,6 +215,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - name: Download Qdrant binary + run: node scripts/download-qdrant.js --current --platform darwin --arch ${{ matrix.arch }} + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - name: Cache Electron uses: actions/cache@v4 with: diff --git a/.gitignore b/.gitignore index 0e3eb2931..d57c43b08 100644 --- a/.gitignore +++ b/.gitignore @@ -314,3 +314,4 @@ Thumbs.db .macos-fast-paste.hash .macos-fast-paste.*.hash .linux-fast-paste.hash +.qdrant-initialized diff --git a/CHANGELOG.md b/CHANGELOG.md index b064dd202..7b1a057d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.6.7] - 2026-04-02 + +### Added + +- **Save Notes as Files**: Export notes to the local filesystem as Markdown files, mirroring folder hierarchy +- **Responsive Settings Dialog**: Settings dialog adapts to narrow windows — sidebar collapses to icon rail, rows stack vertically, plan grid reflows +- **Chat Sidebar**: Full sidebar chat tab with conversation history, cloud sync, and semantic search +- **Chat UX Polish**: Empty state with illustration, shimmer thinking/streaming indicator, stop button, action buttons and search dialog +- **Local Semantic Search**: Always-on Qdrant vector DB sidecar for offline semantic search across notes — hybrid FTS5 + vector with Reciprocal Rank Fusion +- **Agent Tool Calling**: Agentic tool-calling system with note management tools (get, create, update, search), cloud agent support with NDJSON streaming, and local model tool calling with RAG context injection +- **Embedded Chat in Notes**: Embedded chat panel in the note editor with floating and sidebar modes +- **Per-GPU Device Selector**: Choose a specific GPU for transcription and intelligence processing (#539) +- **Settings Keyboard Shortcut**: Cmd+, / Ctrl+, keyboard shortcut to open Settings +- **Notes Actions Button**: Actions sidebar button with redesigned action editor dialog +- **Notes Folder Picker**: Folder picker in the note metadata row with cleaned-up input styles +- **Notes Sidebar Buttons**: New note and search notes buttons in the sidebar +- **Meeting Echo Cancellation**: Echo cancellation on mic input and note metadata chips in meeting view +- **Linux Wrapper Script**: Wrapper script to force XWayland and support user flags (#507) + +### Changed + +- **Vercel AI SDK Migration**: Agent mode migrated from raw API calls to Vercel AI SDK +- **Notes Bottom Bar Redesign**: Redesigned bottom bar with compact action picker +- **Dialog Design System Alignment**: All dialogs aligned with design system guidelines +- **Removed Note Word Count**: Removed word count from note editor +- **Cloud Agent Streaming**: Stream cloud agent responses directly from the renderer via IPC + +### Fixed + +- **Meeting Auto-Detection**: Fix auto-detection not firing for browser meetings +- **Meeting Transcription Provider**: Use local transcription provider for notes/meeting recording (#530) +- **Meeting Partial Transcript Spam**: Prevent partial transcript spam and duplicate final segments +- **Meeting Notification Timing**: Resolve notification popup timing and detection lifecycle bugs +- **Folder/Note Race Conditions**: Resolve race conditions when switching folders quickly, prevent meeting view from exiting when changing folder, fix rapid delete/switch state management +- **Clipboard Preservation**: Preserve images and HTML in clipboard during paste-and-restore (#381) +- **Transcription Retry Provider**: Retry transcription uses configured provider instead of forcing Parakeet +- **JSON Parse Validation**: Validate JSON.parse result type before calling .replace() in prompts (#541) +- **GPU Selector Polish**: Address code review feedback, rename Intelligence GPU label, fix dropdown chevron padding (#539) +- **Chat Duplicate Conversations**: Fix duplicate conversations — includeArchived filter returned all instead of only archived +- **Linux Wayland Fixes**: Force XWayland on KDE/GNOME Wayland, fix hotkey startup race; use uinput before portal on GNOME Wayland (#468, #494) +- **Mic Permission Gate**: Remove mic permission gate, fix system audio detection +- **Windows Build Signing**: Fix Windows build signing on PRs, add missing mic-listener download, add missing publisherName to Azure signing config +- **Dead optimizeAudio Crash**: Remove dead optimizeAudio call that crashes on recordings over 90 seconds (#524) +- **Download URL Logging**: Remove URL truncation from download log and add failure logging (#540) + +### Security + +- **picomatch**: Bump to 4.0.4 +- **brace-expansion**: Bump to 1.1.13 (security backport) +- **yaml**: Bump to 2.8.3 +- **tar**: Bump to 7.5.13 + ## [1.6.6] - 2026-03-19 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index b556ee40a..b94cd01fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,9 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper. - **whisper.js**: Local whisper.cpp integration and model management - **parakeet.js**: NVIDIA Parakeet model management via sherpa-onnx - **parakeetServer.js**: sherpa-onnx CLI wrapper for transcription +- **qdrantManager.js**: Qdrant vector DB sidecar process lifecycle (spawn, health check, shutdown) +- **localEmbeddings.js**: Local text embedding via ONNX Runtime + all-MiniLM-L6-v2 (384-dim vectors) +- **vectorIndex.js**: Qdrant collection management — upsert, delete, search, batch reindex - **windowConfig.js**: Centralized window configuration - **windowManager.js**: Window creation and lifecycle management @@ -161,6 +164,32 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper. - **Download URLs**: Models from sherpa-onnx ASR models release on GitHub +### Local Semantic Search (Qdrant + MiniLM) + +Always-on offline semantic search that finds notes by meaning, not just keywords. Used by the AI agent's `search_notes` tool. Qdrant starts automatically on app launch; embedding model auto-downloads on first run if missing. + +**Architecture**: +- **Qdrant sidecar**: Rust binary spawned as child process (`qdrantManager.js`), port 6333–6350 +- **Embedding model**: `all-MiniLM-L6-v2` via ONNX Runtime (`localEmbeddings.js`), 384-dim vectors +- **Vector index**: Qdrant collection management (`vectorIndex.js`), cosine distance +- **Hybrid search**: FTS5 + Qdrant in parallel → Reciprocal Rank Fusion (K=60) with 0.3 cosine score threshold + +**Pipeline**: +1. App launches → Qdrant binary starts → collection created. Embedding model auto-downloads if missing (~22MB) +2. Note create/update/delete → SQLite write → background vector upsert/delete via `_asyncVectorUpsert()`/`_asyncVectorDelete()` +3. Agent searches → `db-semantic-search-notes` IPC → parallel FTS5 + vector search → RRF merge → ranked results + +**Search fallback chain** (in `searchNotesTool.ts`): cloud search → local semantic → FTS5 keyword + +**Storage**: +- Qdrant data: `~/.cache/openwhispr/qdrant-data/` +- Qdrant binary: `resources/bin/qdrant-{platform}-{arch}` (bundled — downloaded during `prebuild` / `predev`) +- Embedding model: `~/.cache/openwhispr/embedding-models/all-MiniLM-L6-v2/` (auto-downloaded on first launch) + +**Dependencies**: `@qdrant/js-client-rest`, `onnxruntime-node` + +**Dev setup**: The Qdrant binary downloads automatically via `predev`/`prestart`. The embedding model auto-downloads on first app launch. To manually download: `npm run download:qdrant` and `npm run download:embedding-model`. + ### Build Scripts (scripts/) - **download-whisper-cpp.js**: Downloads whisper.cpp binaries from GitHub releases @@ -169,6 +198,8 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper. - **download-windows-key-listener.js**: Downloads prebuilt Windows key listener binary - **download-windows-mic-listener.js**: Downloads prebuilt Windows mic listener binary - **download-sherpa-onnx.js**: Downloads sherpa-onnx binaries for Parakeet support +- **download-qdrant.js**: Downloads Qdrant vector DB binary for local semantic search +- **download-minilm.js**: Downloads all-MiniLM-L6-v2 ONNX model + tokenizer for local embeddings - **build-globe-listener.js**: Compiles macOS Globe key listener from Swift source - **build-macos-mic-listener.js**: Compiles macOS mic listener from Swift source - **build-windows-key-listener.js**: Compiles Windows key listener (for local development) @@ -230,9 +261,9 @@ CREATE TABLE transcriptions ( Settings stored in localStorage with these keys: - `whisperModel`: Selected Whisper model - `useLocalWhisper`: Boolean for local vs cloud -- `openaiApiKey`: Encrypted API key -- `anthropicApiKey`: Encrypted API key -- `geminiApiKey`: Encrypted API key +- `openaiApiKey`: API key (plaintext) +- `anthropicApiKey`: API key (plaintext) +- `geminiApiKey`: API key (plaintext) - `language`: Selected language code - `agentName`: User's custom agent name - `reasoningModel`: Selected AI model for processing @@ -520,7 +551,8 @@ const { t } = useTranslation(); 2. **New Setting**: Update useSettings.ts and SettingsPage.tsx 3. **New UI Component**: Follow shadcn/ui patterns in src/components/ui 4. **New Manager**: Create in src/helpers/, initialize in main.js -5. **New UI Strings**: Add translation keys to all 9 language files (see i18n section above) +5. **New UI Strings**: Add translation keys to all 10 language files (see i18n section above) +6. **New Sidecar Binary**: Add download script in `scripts/`, add to `prebuild*` scripts in package.json, add manager in `src/helpers/`, initialize in `main.js`, shutdown in `will-quit` handler ### Testing Checklist @@ -539,6 +571,9 @@ const { t } = useTranslation(); - [ ] Verify meeting detection works with event-driven mode (check debug logs for "event-driven") - [ ] Test meeting notification suppression during recording - [ ] Test post-recording cooldown (notifications shouldn't flash immediately) +- [ ] Create a note about "quarterly revenue projections", search via agent for "financial forecast" — should match semantically +- [ ] Verify Qdrant starts on app launch (check debug logs for "qdrant started successfully") +- [ ] Kill Qdrant process manually — verify FTS5 keyword search still works as fallback ### Common Issues and Solutions @@ -583,6 +618,14 @@ const { t } = useTranslation(); - Linux: Verify `pactl` is installed (`pulseaudio-utils` or `pipewire-pulse` package) - If event-driven binary is missing, detection falls back to polling automatically +7. **Local Semantic Search Not Working**: + - Qdrant binary should be in `resources/bin/qdrant-{platform}-{arch}` (auto-downloaded during `predev`/`prebuild`) + - Embedding model should be in `~/.cache/openwhispr/embedding-models/all-MiniLM-L6-v2/model.onnx` (auto-downloaded on first app launch) + - Run `npm run download:qdrant` and `npm run download:embedding-model` manually if missing + - Check debug logs for "qdrant" entries (port, health check, errors) + - If Qdrant fails to start, search still works via FTS5 keyword fallback + - Semantic search is only available through the AI agent's `search_notes` tool, not the manual search UI + ### Platform-Specific Notes **macOS**: @@ -650,7 +693,7 @@ const { t } = useTranslation(); ## Security Considerations -- API keys stored in system keychain when possible +- API keys stored in plaintext in userData `.env` and localStorage (not yet encrypted — see #532) - Context isolation enabled - No remote code execution - Sanitized file paths diff --git a/README.md b/README.md index c061b19f5..fc713dd14 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - **Google**: Gemini 3.1 Pro, Gemini 3 Flash, Gemini 2.5 Flash Lite - **Groq**: Ultra-fast inference with Llama and Mixtral models - **Local**: Qwen, LLaMA, Mistral, Gemma models via llama.cpp +- 🛠️ **Agent Tool Calling**: Agentic tools for searching, creating, and updating notes — with RAG context injection for local models - 🤖 **AI Actions**: Apply AI-powered actions to notes with customizable processing templates ### Meeting Transcription @@ -59,8 +60,11 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - 📝 **Notes System**: Create, edit, and organize notes with folders, audio upload, and real-time dictation - 🔎 **Full-Text Search**: FTS5-powered search across all note content with Cmd+K command palette +- 🧠 **Local Semantic Search**: Always-on Qdrant vector DB with MiniLM embeddings for offline meaning-based search across notes - ☁️ **Cloud Sync**: Local-first storage with cloud backup and semantic search +- 💬 **Embedded Chat**: Chat panel in the note editor with floating and sidebar modes - 📁 **Folder Organization**: Organize notes into custom folders with drag-and-drop +- 💾 **Save Notes as Files**: Export notes to the local filesystem as Markdown files, mirroring your folder hierarchy ### Cloud & Account @@ -73,6 +77,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - 🌐 **Cross-Platform**: Works on macOS, Windows, and Linux - 🎨 **Modern UI**: Built with React 19, TypeScript, and Tailwind CSS v4 +- 📱 **Responsive Settings**: Settings dialog adapts to narrow windows with collapsible icon-rail sidebar and stacked layouts - 🖱️ **Draggable Interface**: Move the dictation panel anywhere on your screen with configurable start position - 🌐 **Globe Key Toggle (macOS)**: Optional Fn/Globe key listener for a hardware-level dictation trigger — no Input Monitoring permission required - ⌨️ **Compound Hotkeys**: Support for multi-key combinations like `Cmd+Shift+K` @@ -849,7 +854,7 @@ A: No. As of v1.6.0, OpenWhispr uses NSEvent monitors instead of CGEvent taps, e ## Project Status -OpenWhispr is actively maintained and ready for production use. Current version: 1.6.6 +OpenWhispr is actively maintained and ready for production use. Current version: 1.6.7 - ✅ Core dictation with local and cloud processing - ✅ Cross-platform support (macOS, Windows, Linux) diff --git a/SECURITY.md b/SECURITY.md index ff934f7fa..f2c816f35 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -39,8 +39,11 @@ Out of scope: - **Local-first audio processing** — Audio is transcribed on-device using whisper.cpp or nvidia parakeet. Recordings are not sent to external servers unless explicitly configured by the user. -- **Credential storage** — Sensitive credentials are stored using Electron's - `safeStorage` API where available. +- **Credential storage** — API keys provided by users (BYOK) are stored in + plaintext in the app's `userData` directory (`.env` file and Electron + `localStorage`). They are readable by any process running as the current OS + user. Migrating to Electron's `safeStorage` API for platform-native + encryption is tracked in [#532](https://github.com/OpenWhispr/openwhispr/issues/532). - **Native binaries** — Platform-specific helpers (key listeners, paste utilities) are compiled from source during the build process. - **Context isolation** — The Electron renderer runs with context isolation diff --git a/electron-builder.json b/electron-builder.json index 204767707..4502e751f 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -83,7 +83,7 @@ "!**/node_modules/**/CHANGELOG*", "!**/node_modules/**/.github/**" ], - "asarUnpack": ["**/node_modules/ffmpeg-static/**/*", "**/node_modules/better-sqlite3/**/*"], + "asarUnpack": ["**/node_modules/ffmpeg-static/**/*", "**/node_modules/better-sqlite3/**/*", "**/node_modules/onnxruntime-node/**/*"], "extraResources": [ ".env", "src/assets/**/*", @@ -103,10 +103,12 @@ "whisper-server-*", "llama-server-*", "sherpa-onnx-*", + "qdrant-*", "windows-key-listener*", "windows-mic-listener*", "windows-text-monitor*", "windows-fast-paste*", + "all-MiniLM-L6-v2/**/*", "*.dylib", "*.dll", "*.so*" @@ -133,6 +135,12 @@ "win": { "target": ["nsis", "portable"], "icon": "src/assets/icon.ico", + "azureSignOptions": { + "endpoint": "https://eus.codesigning.azure.net/", + "certificateProfileName": "openwhispr-release", + "codeSigningAccountName": "OpenWhispr", + "publisherName": "CN=Gizmo Labs Inc., O=Gizmo Labs Inc., L=Wilmington, S=Delaware, C=US" + }, "extraResources": [ { "from": "resources/bin/nircmd.exe", diff --git a/main.js b/main.js index 02d7c8a04..8454dea35 100644 --- a/main.js +++ b/main.js @@ -1,3 +1,21 @@ +// KDE/GNOME Wayland: self-relaunch with --ozone-platform=x11 to force XWayland. +// Chromium picks the display backend before JS runs, so appendSwitch is too late. +if ( + process.platform === "linux" && + process.env.XDG_SESSION_TYPE === "wayland" && + !process.argv.includes("--ozone-platform=x11") +) { + const desktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase(); + if (desktop.includes("kde") || /gnome|ubuntu|unity/.test(desktop)) { + const { spawn } = require("child_process"); + spawn(process.execPath, [...process.argv.slice(1), "--ozone-platform=x11"], { + stdio: "inherit", + detached: true, + }).unref(); + process.exit(0); + } +} + const { app, globalShortcut, @@ -62,6 +80,13 @@ function configureChannelUserDataPath() { configureChannelUserDataPath(); +// Load userData .env (contains DICTATION_KEY, API keys, etc.) early — before +// hotkey registration, which needs DICTATION_KEY before the renderer loads. +require("dotenv").config({ + path: path.join(app.getPath("userData"), ".env"), + override: false, +}); + // Fix transparent window flickering on Linux: --enable-transparent-visuals requires // the compositor to set up an ARGB visual before any windows are created. // --disable-gpu-compositing prevents GPU compositing conflicts with the compositor. @@ -79,13 +104,7 @@ if (process.platform === "win32") { // force --ozone-platform=x11 before Electron starts. appendSwitch below is a // best-effort fallback for unpackaged dev mode (may not take effect on E39+). if (process.platform === "linux" && process.env.XDG_SESSION_TYPE === "wayland") { - const desktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase(); - if (desktop.includes("kde")) { - app.commandLine.appendSwitch("ozone-platform-hint", "x11"); - } else { - app.commandLine.appendSwitch("ozone-platform-hint", "auto"); - } - app.commandLine.appendSwitch("enable-features", "UseOzonePlatform,WaylandWindowDecorations"); + app.commandLine.appendSwitch("enable-features", "WaylandWindowDecorations"); } // Set desktop filename so Wayland compositors can match windows to the .desktop entry. @@ -209,6 +228,7 @@ let whisperCudaManager = null; let googleCalendarManager = null; let meetingDetectionEngine = null; let audioTapManager = null; +let qdrantManager = null; let ipcHandlers = null; let globeKeyAlertShown = false; let authBridgeServer = null; @@ -664,6 +684,32 @@ async function startApp() { }); } + const QdrantManager = require("./src/helpers/qdrantManager"); + qdrantManager = new QdrantManager(); + if (qdrantManager.isAvailable()) { + qdrantManager + .start() + .then(() => { + if (qdrantManager.isReady()) { + const vectorIndex = require("./src/helpers/vectorIndex"); + vectorIndex.init(qdrantManager.getPort()); + vectorIndex.ensureCollection().catch((err) => { + debugLogger.debug("Qdrant collection setup error (non-fatal)", { error: err.message }); + }); + } + }) + .catch((err) => { + debugLogger.debug("Qdrant startup error (non-fatal)", { error: err.message }); + }); + } + + const localEmbeddings = require("./src/helpers/localEmbeddings"); + if (!localEmbeddings.isAvailable()) { + localEmbeddings.downloadModel().catch((err) => { + debugLogger.debug("Embedding model download error (non-fatal)", { error: err.message }); + }); + } + if (process.platform === "win32") { const nircmdStatus = clipboardManager.getNircmdStatus(); debugLogger.debug("Windows paste tool status", nircmdStatus); @@ -1144,5 +1190,8 @@ if (gotSingleInstanceLock) { // Stop llama-server if running const modelManager = require("./src/helpers/modelManagerBridge").default; modelManager.stopServer().catch(() => {}); + if (qdrantManager) { + qdrantManager.stop().catch(() => {}); + } }); } diff --git a/package-lock.json b/package-lock.json index 92e705617..37c1f9b53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,22 @@ { "name": "open-whispr", - "version": "1.6.6", + "version": "1.6.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-whispr", - "version": "1.6.6", + "version": "1.6.7", "hasInstallScript": true, "license": "MIT", "dependencies": { + "@ai-sdk/anthropic": "^3.0.58", + "@ai-sdk/google": "^3.0.43", + "@ai-sdk/groq": "^3.0.29", + "@ai-sdk/openai": "^3.0.41", "@neondatabase/auth": "^0.2.0-beta.1", "@neondatabase/neon-js": "^0.1.0-beta.22", + "@qdrant/js-client-rest": "^1.12.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", @@ -20,6 +25,7 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", + "@tanstack/react-virtual": "^3.13.2", "@tiptap/core": "^3.20.4", "@tiptap/extension-placeholder": "^3.20.4", "@tiptap/extension-task-item": "^3.20.4", @@ -27,6 +33,7 @@ "@tiptap/react": "^3.20.4", "@tiptap/starter-kit": "^3.20.4", "@vercel/blob": "^2.3.0", + "ai": "^6.0.116", "better-sqlite3": "^12.8.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -39,6 +46,7 @@ "kysely": "^0.28.14", "lucide-react": "^0.518.0", "object-assign": "^4.1.1", + "onnxruntime-node": "^1.21.0", "ps-list": "^9.0.0", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -82,6 +90,116 @@ "node": ">=22" } }, + "node_modules/@ai-sdk/anthropic": { + "version": "3.0.64", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.64.tgz", + "integrity": "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.21" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.84", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.84.tgz", + "integrity": "sha512-RnUw6UNvkaw9MEaJU9cIjA+WBP+ZR5+M/9nfbfJHcGKtTbcWXijJuYKx9nYRnm+qU+iiakb0XvQA/vvho6lTsw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.21", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "3.0.55", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.55.tgz", + "integrity": "sha512-lIID7kDdeqzSR/eg+LrEFJGoe/mIwuW4eBdjS84Bi0cIY4cmGwSsvwYILSdiad0197Ha+DUbCxOUuSOnORUx7g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.21" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/groq": { + "version": "3.0.32", + "resolved": "https://registry.npmjs.org/@ai-sdk/groq/-/groq-3.0.32.tgz", + "integrity": "sha512-cKa+viBUgkY0GVWDAMYYOaRS+r9PNJvstfLujqi8YQSlpknQsE+J9TeG8b8bjkAnMnCpUd8s/ZEJHMuKzp+rYQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.21" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.49", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.49.tgz", + "integrity": "sha512-U2f0pCyNn/jQH3wjgxr8o9VvCkuDFTtXbIhbFFtgXqCzMbed6rBnvzQcAMEK0/Pa44byL9zfcvCOFOflvkRA8w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.21" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.21", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.21.tgz", + "integrity": "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "dev": true, @@ -3441,6 +3559,33 @@ "@opentelemetry/api": "^1.8" } }, + "node_modules/@qdrant/js-client-rest": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@qdrant/js-client-rest/-/js-client-rest-1.17.0.tgz", + "integrity": "sha512-aZFQeirWVqWAa1a8vJ957LMzcXkFHGbsoRhzc8AkGfg6V0jtK8PlG8/eyyc2xhYsR961FDDx1Tx6nyE0K7lS+A==", + "license": "Apache-2.0", + "dependencies": { + "@qdrant/openapi-typescript-fetch": "1.2.6", + "undici": "^6.23.0" + }, + "engines": { + "node": ">=18.17.0", + "pnpm": ">=8" + }, + "peerDependencies": { + "typescript": ">=4.7" + } + }, + "node_modules/@qdrant/openapi-typescript-fetch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@qdrant/openapi-typescript-fetch/-/openapi-typescript-fetch-1.2.6.tgz", + "integrity": "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "pnpm": ">=8" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "license": "MIT" @@ -5693,6 +5838,33 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", + "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tiptap/core": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.21.0.tgz", @@ -6684,6 +6856,15 @@ "node": ">=20.0.0" } }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "dev": true, @@ -6775,6 +6956,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "dev": true, @@ -6783,6 +6973,24 @@ "node": ">= 14" } }, + "node_modules/ai": { + "version": "6.0.142", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.142.tgz", + "integrity": "sha512-ZoxAsnTL/dFg5WdcwC8QNhKVlLtqwwT3I7p/4i8IJJP+6ZwqF1ljuwMsAsPYYvppZ+RzUxjxxFGb1cbEhNH3dg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.84", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.21", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "6.14.0", "dev": true, @@ -7359,9 +7567,7 @@ }, "node_modules/boolean": { "version": "3.2.0", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/brace-expansion": { "version": "2.0.3", @@ -8114,9 +8320,7 @@ }, "node_modules/define-data-property": { "version": "1.1.4", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -8131,9 +8335,7 @@ }, "node_modules/define-properties": { "version": "1.2.1", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -8194,9 +8396,7 @@ }, "node_modules/detect-node": { "version": "2.1.0", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/detect-node-es": { "version": "1.1.0", @@ -8756,7 +8956,6 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8764,7 +8963,6 @@ }, "node_modules/es-errors": { "version": "1.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8797,9 +8995,7 @@ }, "node_modules/es6-error": { "version": "4.1.1", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/esbuild": { "version": "0.25.12", @@ -9088,7 +9284,6 @@ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" } @@ -9689,9 +9884,7 @@ }, "node_modules/global-agent": { "version": "3.0.0", - "dev": true, "license": "BSD-3-Clause", - "optional": true, "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", @@ -9717,9 +9910,7 @@ }, "node_modules/globalthis": { "version": "1.0.4", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" @@ -9733,7 +9924,6 @@ }, "node_modules/gopd": { "version": "1.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9780,9 +9970,7 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -10445,6 +10633,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-ref-resolver": { "version": "1.0.1", "license": "MIT", @@ -10479,9 +10673,7 @@ }, "node_modules/json-stringify-safe": { "version": "5.0.1", - "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/json5": { "version": "2.2.3", @@ -10788,9 +10980,7 @@ }, "node_modules/matcher": { "version": "3.0.0", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "escape-string-regexp": "^4.0.0" }, @@ -11798,9 +11988,7 @@ }, "node_modules/object-keys": { "version": "1.1.1", - "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 0.4" } @@ -11837,6 +12025,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/onnxruntime-common": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", + "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", + "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^3.0.0", + "onnxruntime-common": "1.24.3" + } + }, "node_modules/openapi-types": { "version": "12.1.3", "license": "MIT" @@ -13235,9 +13446,7 @@ }, "node_modules/roarr": { "version": "2.15.4", - "dev": true, "license": "BSD-3-Clause", - "optional": true, "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", @@ -13393,15 +13602,11 @@ }, "node_modules/semver-compare": { "version": "1.0.0", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/serialize-error": { "version": "7.0.1", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "type-fest": "^0.13.1" }, @@ -13655,9 +13860,7 @@ }, "node_modules/sprintf-js": { "version": "1.1.3", - "dev": true, - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/sql-formatter": { "version": "14.0.0", @@ -14183,9 +14386,7 @@ }, "node_modules/type-fest": { "version": "0.13.1", - "dev": true, "license": "(MIT OR CC0-1.0)", - "optional": true, "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index 35362526b..d3bd57df4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-whispr", - "version": "1.6.6", + "version": "1.6.7", "description": "A desktop dictation application using whisper.cpp for speech-to-text transcription", "main": "main.js", "private": true, @@ -18,33 +18,33 @@ "compile:mic-listener": "node scripts/build-macos-mic-listener.js", "compile:audio-tap": "node scripts/build-macos-audio-tap.js", "compile:native": "npm run compile:globe && npm run compile:fast-paste && npm run compile:winkeys && npm run compile:winpaste && npm run compile:linux-paste && npm run compile:text-monitor && npm run compile:media-remote && npm run compile:mic-listener && npm run compile:audio-tap", - "prestart": "npm run compile:native", + "prestart": "npm run compile:native && npm run download:qdrant && npm run download:embedding-model", "start": "electron .", - "predev": "npm run compile:native", + "predev": "npm run compile:native && npm run download:qdrant && npm run download:embedding-model", "dev": "concurrently -k -r \"npm:dev:renderer\" \"npm:dev:main\"", - "predev:main": "npm run compile:native", + "predev:main": "npm run compile:native && npm run download:qdrant && npm run download:embedding-model", "dev:main": "cross-env NODE_ENV=development node scripts/run-electron.js --dev", "dev:renderer": "cd src && vite", - "prebuild": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx", + "prebuild": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx && npm run download:qdrant && npm run download:embedding-model -- --for-build", "build": "cd src && vite build && cd .. && electron-builder", "postinstall": "electron-builder install-app-deps", "build:renderer": "cd src && vite build", - "prebuild:mac": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx", + "prebuild:mac": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx && npm run download:qdrant", "build:mac": "npm run build:renderer && electron-builder --mac", "build:mac:arm64": "npm run build:renderer && electron-builder --mac --arm64", "build:mac:x64": "npm run build:renderer && electron-builder --mac --x64", - "prebuild:win": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx && npm run download:nircmd && npm run download:windows-fast-paste && npm run download:windows-key-listener && npm run download:windows-mic-listener", + "prebuild:win": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx && npm run download:qdrant && npm run download:nircmd && npm run download:windows-fast-paste && npm run download:windows-key-listener && npm run download:windows-mic-listener", "build:win": "npm run build:renderer && electron-builder --win", - "prebuild:linux": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx", + "prebuild:linux": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx && npm run download:qdrant", "build:linux": "npm run build:renderer && electron-builder --linux", "build:linux:appimage": "npm run build:renderer && electron-builder --linux AppImage", "build:linux:deb": "npm run build:renderer && electron-builder --linux deb", "build:linux:rpm": "npm run build:renderer && electron-builder --linux rpm", "build:linux:tar": "npm run build:renderer && electron-builder --linux tar.gz", "setup": "node setup.js", - "prepack": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx", + "prepack": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx && npm run download:qdrant", "pack": "npm run build:renderer && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --dir", - "predist": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx", + "predist": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx && npm run download:qdrant", "dist": "npm run build:renderer && electron-builder", "download:whisper-cpp": "node scripts/download-whisper-cpp.js --current", "download:whisper-cpp:all": "node scripts/download-whisper-cpp.js --all", @@ -56,6 +56,9 @@ "download:windows-fast-paste": "node scripts/download-windows-fast-paste.js", "download:windows-key-listener": "node scripts/download-windows-key-listener.js", "download:windows-mic-listener": "node scripts/download-windows-mic-listener.js", + "download:qdrant": "node scripts/download-qdrant.js --current", + "download:qdrant:all": "node scripts/download-qdrant.js --all", + "download:embedding-model": "node scripts/download-minilm.js", "format:js": "eslint . --fix && cd src && eslint . --fix && prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"", "format": "npm run format:js", "format:check": "eslint . && cd src && eslint . && prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\"", @@ -103,8 +106,14 @@ "vite": "^6.3.5" }, "dependencies": { + "@ai-sdk/anthropic": "^3.0.58", + "@ai-sdk/google": "^3.0.43", + "@ai-sdk/groq": "^3.0.29", + "@ai-sdk/openai": "^3.0.41", + "@qdrant/js-client-rest": "^1.12.0", "@neondatabase/auth": "^0.2.0-beta.1", "@neondatabase/neon-js": "^0.1.0-beta.22", + "@tanstack/react-virtual": "^3.13.2", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", @@ -120,6 +129,7 @@ "@tiptap/react": "^3.20.4", "@tiptap/starter-kit": "^3.20.4", "@vercel/blob": "^2.3.0", + "ai": "^6.0.116", "better-sqlite3": "^12.8.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -132,6 +142,7 @@ "kysely": "^0.28.14", "lucide-react": "^0.518.0", "object-assign": "^4.1.1", + "onnxruntime-node": "^1.21.0", "ps-list": "^9.0.0", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/preload.js b/preload.js index 64329fb7e..3688ff95d 100644 --- a/preload.js +++ b/preload.js @@ -85,6 +85,14 @@ contextBridge.exposeInMainWorld("electronAPI", { deleteNote: (id) => ipcRenderer.invoke("db-delete-note", id), exportNote: (noteId, format) => ipcRenderer.invoke("export-note", noteId, format), searchNotes: (query, limit) => ipcRenderer.invoke("db-search-notes", query, limit), + semanticSearchNotes: (query, limit) => + ipcRenderer.invoke("db-semantic-search-notes", query, limit), + semanticReindexAll: () => ipcRenderer.invoke("db-semantic-reindex-all"), + onSemanticReindexProgress: (callback) => { + const listener = (_event, data) => callback?.(data); + ipcRenderer.on("semantic-reindex-progress", listener); + return () => ipcRenderer.removeListener("semantic-reindex-progress", listener); + }, updateNoteCloudId: (id, cloudId) => ipcRenderer.invoke("db-update-note-cloud-id", id, cloudId), // Folder functions @@ -94,6 +102,16 @@ contextBridge.exposeInMainWorld("electronAPI", { renameFolder: (id, name) => ipcRenderer.invoke("db-rename-folder", id, name), getFolderNoteCounts: () => ipcRenderer.invoke("db-get-folder-note-counts"), + // Note files (markdown mirror) functions + noteFilesSetEnabled: (enabled, customPath) => + ipcRenderer.invoke("note-files-set-enabled", enabled, customPath), + noteFilesSetPath: (path) => ipcRenderer.invoke("note-files-set-path", path), + noteFilesRebuild: () => ipcRenderer.invoke("note-files-rebuild"), + noteFilesGetDefaultPath: () => ipcRenderer.invoke("note-files-get-default-path"), + noteFilesPickFolder: () => ipcRenderer.invoke("note-files-pick-folder"), + showNoteFile: (noteId) => ipcRenderer.invoke("show-note-file", noteId), + showFolderInExplorer: (folderName) => ipcRenderer.invoke("show-folder-in-explorer", folderName), + // Action functions getActions: () => ipcRenderer.invoke("db-get-actions"), getAction: (id) => ipcRenderer.invoke("db-get-action", id), @@ -530,6 +548,9 @@ contextBridge.exposeInMainWorld("electronAPI", { }, onWindowsPushToTalkUnavailable: registerListener("windows-ptt-unavailable"), + // Settings shortcut (Cmd+, / Ctrl+,) + onShowSettings: registerListener("show-settings", (callback) => () => callback()), + // Accessibility permission events (macOS) onAccessibilityMissing: (callback) => { const listener = () => callback?.(); @@ -581,13 +602,22 @@ contextBridge.exposeInMainWorld("electronAPI", { acquireRecordingLock: (pipeline) => ipcRenderer.invoke("acquire-recording-lock", pipeline), releaseRecordingLock: (pipeline) => ipcRenderer.invoke("release-recording-lock", pipeline), - // Agent cloud streaming - cloudAgentStream: (messages, opts) => ipcRenderer.invoke("cloud-agent-stream", messages, opts), + // Agent cloud streaming (event-based for real-time chunks) + startAgentStream: (messages, opts) => + ipcRenderer.send("cloud-agent-stream-start", messages, opts), onAgentStreamChunk: registerListener( - "agent-stream-chunk", + "cloud-agent-stream-chunk", (callback) => (_event, chunk) => callback(chunk) ), - onAgentStreamDone: registerListener("agent-stream-done", (callback) => () => callback()), + onAgentStreamError: registerListener( + "cloud-agent-stream-error", + (callback) => (_event, error) => callback(error) + ), + onAgentStreamEnd: registerListener("cloud-agent-stream-end", (callback) => () => callback()), + + // Agent cloud tools + agentWebSearch: (query, numResults) => ipcRenderer.invoke("agent-web-search", query, numResults), + agentOpenNote: (noteId) => ipcRenderer.invoke("agent-open-note", noteId), // Agent conversation persistence createAgentConversation: (title) => ipcRenderer.invoke("db-create-agent-conversation", title), @@ -596,9 +626,19 @@ contextBridge.exposeInMainWorld("electronAPI", { deleteAgentConversation: (id) => ipcRenderer.invoke("db-delete-agent-conversation", id), updateAgentConversationTitle: (id, title) => ipcRenderer.invoke("db-update-agent-conversation-title", id, title), - addAgentMessage: (conversationId, role, content) => - ipcRenderer.invoke("db-add-agent-message", conversationId, role, content), + addAgentMessage: (conversationId, role, content, metadata) => + ipcRenderer.invoke("db-add-agent-message", conversationId, role, content, metadata), getAgentMessages: (conversationId) => ipcRenderer.invoke("db-get-agent-messages", conversationId), + getAgentConversationsWithPreview: (limit, offset, includeArchived) => + ipcRenderer.invoke("db-get-agent-conversations-with-preview", limit, offset, includeArchived), + searchAgentConversations: (query, limit) => + ipcRenderer.invoke("db-search-agent-conversations", query, limit), + archiveAgentConversation: (id) => ipcRenderer.invoke("db-archive-agent-conversation", id), + unarchiveAgentConversation: (id) => ipcRenderer.invoke("db-unarchive-agent-conversation", id), + updateAgentConversationCloudId: (id, cloudId) => + ipcRenderer.invoke("db-update-agent-conversation-cloud-id", id, cloudId), + semanticSearchConversations: (query, limit) => + ipcRenderer.invoke("db-semantic-search-conversations", query, limit), // Google Calendar gcalStartOAuth: () => ipcRenderer.invoke("gcal-start-oauth"), @@ -610,6 +650,7 @@ contextBridge.exposeInMainWorld("electronAPI", { gcalSyncEvents: () => ipcRenderer.invoke("gcal-sync-events"), gcalGetUpcomingEvents: (windowMinutes) => ipcRenderer.invoke("gcal-get-upcoming-events", windowMinutes), + gcalGetEvent: (eventId) => ipcRenderer.invoke("gcal-get-event", eventId), // Google Calendar event listeners onGcalMeetingStarting: registerListener( @@ -637,8 +678,6 @@ contextBridge.exposeInMainWorld("electronAPI", { meetingDetectionGetPreferences: () => ipcRenderer.invoke("meeting-detection-get-preferences"), meetingDetectionSetPreferences: (prefs) => ipcRenderer.invoke("meeting-detection-set-preferences", prefs), - meetingDetectionRespond: (detectionId, action) => - ipcRenderer.invoke("meeting-detection-respond", detectionId, action), onMeetingDetected: registerListener( "meeting-detected", (callback) => (_event, data) => callback(data) @@ -659,6 +698,10 @@ contextBridge.exposeInMainWorld("electronAPI", { "navigate-to-meeting-note", (callback) => (_event, data) => callback(data) ), + onNavigateToNote: registerListener( + "navigate-to-note", + (callback) => (_event, data) => callback(data) + ), onUpdateNotificationData: registerListener( "update-notification-data", diff --git a/resources/macos-mic-listener.swift b/resources/macos-mic-listener.swift index 9154e374a..4baa1eea3 100644 --- a/resources/macos-mic-listener.swift +++ b/resources/macos-mic-listener.swift @@ -107,7 +107,7 @@ func getInputDevices() -> [AudioDeviceID] { func isDeviceRunning(_ deviceID: AudioDeviceID) -> Bool { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, - mScope: kAudioObjectPropertyScopeInput, + mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) var isRunning: UInt32 = 0 @@ -187,7 +187,7 @@ let deviceListListener: AudioObjectPropertyListenerProc = { func registerRunningListener(on deviceID: AudioDeviceID) { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, - mScope: kAudioObjectPropertyScopeInput, + mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) @@ -200,7 +200,7 @@ func registerRunningListener(on deviceID: AudioDeviceID) { func removeRunningListener(from deviceID: AudioDeviceID) { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, - mScope: kAudioObjectPropertyScopeInput, + mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) @@ -285,5 +285,13 @@ let active = isAnyInputRunning() previouslyActive = active emit(active ? "MIC_ACTIVE" : "MIC_INACTIVE") +// Heartbeat: periodic check in case property listeners miss events +let heartbeatTimer = DispatchSource.makeTimerSource(queue: .main) +heartbeatTimer.schedule(deadline: .now() + 5, repeating: 5) +heartbeatTimer.setEventHandler { + checkAndEmitState() +} +heartbeatTimer.resume() + // Keep the process alive CFRunLoopRun() diff --git a/scripts/download-minilm.js b/scripts/download-minilm.js new file mode 100644 index 000000000..a590d10dd --- /dev/null +++ b/scripts/download-minilm.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { downloadFile, parseArgs } = require("./lib/download-utils"); + +const forBuild = process.argv.includes("--for-build"); +const MODEL_DIR = forBuild + ? path.join(__dirname, "..", "resources", "bin", "all-MiniLM-L6-v2") + : path.join(os.homedir(), ".cache", "openwhispr", "embedding-models", "all-MiniLM-L6-v2"); + +const FILES = [ + { + name: "model.onnx", + url: "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx", + }, + { + name: "tokenizer.json", + url: "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json", + }, +]; + +async function main() { + console.log("\n[embedding-model] Downloading all-MiniLM-L6-v2 ONNX model...\n"); + + const args = parseArgs(); + + const allExist = FILES.every((f) => fs.existsSync(path.join(MODEL_DIR, f.name))); + if (allExist && !args.isForce) { + console.log("[embedding-model] Model files already exist (use --force to re-download)\n"); + return; + } + + fs.mkdirSync(MODEL_DIR, { recursive: true }); + + for (const file of FILES) { + const destPath = path.join(MODEL_DIR, file.name); + + if (fs.existsSync(destPath) && !args.isForce) { + console.log(`[embedding-model] ${file.name} already exists, skipping`); + continue; + } + + console.log(`[embedding-model] Downloading ${file.name} from ${file.url}`); + + try { + await downloadFile(file.url, destPath); + const stats = fs.statSync(destPath); + const sizeMB = (stats.size / 1024 / 1024).toFixed(1); + console.log(`[embedding-model] ${file.name} downloaded (${sizeMB}MB)`); + } catch (error) { + console.error(`[embedding-model] Failed to download ${file.name}: ${error.message}`); + if (fs.existsSync(destPath)) fs.unlinkSync(destPath); + process.exitCode = 1; + return; + } + } + + console.log(`\n[embedding-model] Model ready at ${MODEL_DIR}\n`); +} + +main().catch(console.error); diff --git a/scripts/download-qdrant.js b/scripts/download-qdrant.js new file mode 100644 index 000000000..308645bf7 --- /dev/null +++ b/scripts/download-qdrant.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); +const { + downloadFile, + extractArchive, + fetchLatestRelease, + findBinaryInDir, + parseArgs, + setExecutable, + cleanupFiles, +} = require("./lib/download-utils"); + +const QDRANT_REPO = "qdrant/qdrant"; + +// Version can be pinned via environment variable for reproducible builds +const VERSION_OVERRIDE = process.env.QDRANT_VERSION || null; + +const BINARIES = { + "darwin-arm64": { + archiveName: "qdrant-aarch64-apple-darwin.tar.gz", + binaryName: "qdrant", + outputName: "qdrant-darwin-arm64", + }, + "darwin-x64": { + archiveName: "qdrant-x86_64-apple-darwin.tar.gz", + binaryName: "qdrant", + outputName: "qdrant-darwin-x64", + }, + "linux-x64": { + archiveName: "qdrant-x86_64-unknown-linux-gnu.tar.gz", + binaryName: "qdrant", + outputName: "qdrant-linux-x64", + }, + "win32-x64": { + archiveName: "qdrant-x86_64-pc-windows-msvc.zip", + binaryName: "qdrant.exe", + outputName: "qdrant-win32-x64.exe", + }, +}; + +const BIN_DIR = path.join(__dirname, "..", "resources", "bin"); + +// Cache the release info to avoid multiple API calls +let cachedRelease = null; + +async function getRelease() { + if (cachedRelease) return cachedRelease; + + if (VERSION_OVERRIDE) { + cachedRelease = await fetchLatestRelease(QDRANT_REPO, { tagPrefix: VERSION_OVERRIDE }); + } else { + cachedRelease = await fetchLatestRelease(QDRANT_REPO); + } + return cachedRelease; +} + +function getDownloadUrl(release, archiveName) { + const asset = release?.assets?.find((a) => a.name === archiveName); + return asset?.url || null; +} + +async function downloadBinary(platformArch, config, release, isForce = false) { + if (!config) { + console.log(` [qdrant] ${platformArch}: Not supported`); + return false; + } + + const outputPath = path.join(BIN_DIR, config.outputName); + + if (fs.existsSync(outputPath) && !isForce) { + console.log(` [qdrant] ${platformArch}: Already exists (use --force to re-download)`); + return true; + } + + const url = getDownloadUrl(release, config.archiveName); + if (!url) { + console.error(` [qdrant] ${platformArch}: Asset ${config.archiveName} not found in release`); + return false; + } + console.log(` [qdrant] ${platformArch}: Downloading from ${url}`); + + const archivePath = path.join(BIN_DIR, config.archiveName); + + try { + await downloadFile(url, archivePath); + + const extractDir = path.join(BIN_DIR, `temp-qdrant-${platformArch}`); + fs.mkdirSync(extractDir, { recursive: true }); + await extractArchive(archivePath, extractDir); + + const binaryPath = findBinaryInDir(extractDir, config.binaryName); + if (binaryPath) { + fs.copyFileSync(binaryPath, outputPath); + setExecutable(outputPath); + console.log(` [qdrant] ${platformArch}: Extracted to ${config.outputName}`); + } else { + console.error( + ` [qdrant] ${platformArch}: Binary "${config.binaryName}" not found in archive` + ); + return false; + } + + fs.rmSync(extractDir, { recursive: true, force: true }); + if (fs.existsSync(archivePath)) fs.unlinkSync(archivePath); + return true; + } catch (error) { + console.error(` [qdrant] ${platformArch}: Failed - ${error.message}`); + if (fs.existsSync(archivePath)) fs.unlinkSync(archivePath); + return false; + } +} + +async function main() { + if (VERSION_OVERRIDE) { + console.log(`\n[qdrant] Using pinned version: ${VERSION_OVERRIDE}`); + } else { + console.log("\n[qdrant] Fetching latest release..."); + } + const release = await getRelease(); + + if (!release) { + console.error(`[qdrant] Could not fetch release from ${QDRANT_REPO}`); + console.log(`\nMake sure release exists: https://github.com/${QDRANT_REPO}/releases`); + process.exitCode = 1; + return; + } + + console.log(`\nDownloading qdrant binaries (${release.tag})...\n`); + + fs.mkdirSync(BIN_DIR, { recursive: true }); + + const args = parseArgs(); + + if (args.isCurrent) { + if (!BINARIES[args.platformArch]) { + console.error(`Unsupported platform/arch: ${args.platformArch}`); + process.exitCode = 1; + return; + } + + console.log(`Downloading for target platform (${args.platformArch}):`); + const ok = await downloadBinary( + args.platformArch, + BINARIES[args.platformArch], + release, + args.isForce + ); + if (!ok) { + console.error(`Failed to download binaries for ${args.platformArch}`); + process.exitCode = 1; + return; + } + + if (args.shouldCleanup) { + cleanupFiles(BIN_DIR, "qdrant", `qdrant-${args.platformArch}`); + } + } else { + console.log("Downloading binaries for all platforms:"); + for (const platformArch of Object.keys(BINARIES)) { + await downloadBinary(platformArch, BINARIES[platformArch], release, args.isForce); + } + } + + console.log("\n---"); + + const files = fs.readdirSync(BIN_DIR).filter((f) => f.startsWith("qdrant")); + if (files.length > 0) { + console.log("Available qdrant binaries:\n"); + files.forEach((f) => { + const stats = fs.statSync(path.join(BIN_DIR, f)); + console.log(` - ${f} (${Math.round(stats.size / 1024 / 1024)}MB)`); + }); + } else { + console.log("No binaries downloaded yet."); + console.log(`\nMake sure release exists: https://github.com/${QDRANT_REPO}/releases`); + } +} + +main().catch(console.error); diff --git a/scripts/lib/download-utils.js b/scripts/lib/download-utils.js index 1135858c9..18d0018b1 100644 --- a/scripts/lib/download-utils.js +++ b/scripts/lib/download-utils.js @@ -39,12 +39,15 @@ function fetchJson(url, redirectCount = 0) { https .get(url, options, (res) => { - if (res.statusCode === 301 || res.statusCode === 302) { - const redirectUrl = res.headers.location; - if (!redirectUrl) { + if ([301, 302, 303, 307, 308].includes(res.statusCode)) { + const location = res.headers.location; + if (!location) { reject(new Error("Redirect without location header")); return; } + const redirectUrl = location.startsWith("/") + ? new URL(location, url).href + : location; fetchJson(redirectUrl, redirectCount + 1) .then(resolve) .catch(reject); @@ -156,13 +159,16 @@ function downloadFile(url, dest, retryCount = 0) { } activeRequest = https.get(currentUrl, (response) => { - if (response.statusCode === 302 || response.statusCode === 301) { - const redirectUrl = response.headers.location; - if (!redirectUrl) { + if ([301, 302, 303, 307, 308].includes(response.statusCode)) { + const location = response.headers.location; + if (!location) { cleanup(); reject(new Error("Redirect without location header")); return; } + const redirectUrl = location.startsWith("/") + ? new URL(location, currentUrl).href + : location; request(redirectUrl, redirectCount + 1); return; } diff --git a/scripts/run-electron.js b/scripts/run-electron.js index 8bb7ae72e..68ec180cd 100644 --- a/scripts/run-electron.js +++ b/scripts/run-electron.js @@ -25,8 +25,24 @@ console.log("[run-electron] Electron path:", electronPath); console.log("[run-electron] App dir:", appDir); console.log("[run-electron] Args:", args); -// Spawn electron with the cleaned environment -const child = spawn(electronPath, [appDir, ...args], { +// On KDE/GNOME Wayland, force XWayland so globalShortcut and window positioning work. +// Adding it here avoids the self-relaunch in main.js which kills concurrently in dev mode. +if ( + process.platform === "linux" && + process.env.XDG_SESSION_TYPE === "wayland" && + !args.includes("--ozone-platform=x11") +) { + const desktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase(); + if (desktop.includes("kde") || /gnome|ubuntu|unity/.test(desktop)) { + args.push("--ozone-platform=x11"); + console.log("[run-electron] KDE/GNOME Wayland detected, forcing XWayland"); + } +} + +// Chromium flags must come before the app path, app args after. +const chromiumFlags = args.filter((a) => a.startsWith("--ozone-platform=")); +const appArgs = args.filter((a) => !a.startsWith("--ozone-platform=")); +const child = spawn(electronPath, [...chromiumFlags, appDir, ...appArgs], { stdio: "inherit", env: process.env, cwd: appDir, diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index ec0e44329..8bdb82075 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -1,157 +1,87 @@ import { useState, useEffect, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { cn } from "./lib/utils"; import { AgentTitleBar } from "./agent/AgentTitleBar"; import { AgentChat } from "./agent/AgentChat"; import { AgentInput } from "./agent/AgentInput"; import AudioManager from "../helpers/audioManager"; -import ReasoningService from "../services/ReasoningService"; -import { getSettings } from "../stores/settingsStore"; -import { getAgentSystemPrompt } from "../config/prompts"; - -type AgentState = "idle" | "listening" | "transcribing" | "thinking" | "streaming"; - -interface Message { - id: string; - role: "user" | "assistant"; - content: string; - isStreaming: boolean; -} +import { useChatPersistence } from "./chat/useChatPersistence"; +import { useChatStreaming } from "./chat/useChatStreaming"; +import type { Message } from "./chat/types"; const MIN_HEIGHT = 200; const MIN_WIDTH = 360; export default function AgentOverlay() { - const [messages, setMessages] = useState([]); - const [agentState, setAgentState] = useState("idle"); + const { t } = useTranslation(); const [partialTranscript, setPartialTranscript] = useState(""); const audioManagerRef = useRef | null>(null); - const messagesRef = useRef([]); - const agentStateRef = useRef("idle"); - const conversationIdRef = useRef(null); + const agentStateRef = useRef("idle"); - useEffect(() => { - messagesRef.current = messages; - }, [messages]); + const persistence = useChatPersistence(); + const { messages, setMessages, handleNewChat: persistenceNewChat } = persistence; + + const streaming = useChatStreaming({ + messages, + setMessages, + onStreamComplete: (_assistantId, content, toolCalls) => { + persistence.saveAssistantMessage(content, toolCalls); + }, + }); + + const { agentState } = streaming; useEffect(() => { agentStateRef.current = agentState; }, [agentState]); - const addSystemMessage = useCallback((content: string) => { - setMessages((prev) => [ - ...prev, - { id: crypto.randomUUID(), role: "assistant" as const, content, isStreaming: false }, - ]); - }, []); + const addSystemMessage = useCallback( + (content: string) => { + setMessages((prev) => [ + ...prev, + { id: crypto.randomUUID(), role: "assistant" as const, content, isStreaming: false }, + ]); + }, + [setMessages] + ); - const handleTranscriptionComplete = useCallback(async (text: string) => { - if (!text.trim()) { - setAgentState("idle"); - return; - } - - // Create conversation on first message - if (!conversationIdRef.current) { - const conv = await window.electronAPI?.createAgentConversation?.("New conversation"); - conversationIdRef.current = conv?.id ?? null; - } - - const userMsg: Message = { - id: crypto.randomUUID(), - role: "user", - content: text, - isStreaming: false, - }; - setMessages((prev) => [...prev, userMsg]); - - if (conversationIdRef.current) { - window.electronAPI?.addAgentMessage?.(conversationIdRef.current, "user", text); - } - - // Auto-title after first user message - const allMessages = messagesRef.current; - if (conversationIdRef.current && allMessages.length === 0) { - const title = text.slice(0, 50) + (text.length > 50 ? "..." : ""); - window.electronAPI?.updateAgentConversationTitle?.(conversationIdRef.current, title); - } - - setAgentState("thinking"); - - const settings = getSettings(); - const systemPrompt = getAgentSystemPrompt(); - - const llmMessages = [ - { role: "system", content: systemPrompt }, - ...[...allMessages, userMsg].slice(-20).map((m) => ({ role: m.role, content: m.content })), - ]; - - const assistantId = crypto.randomUUID(); - setMessages((prev) => [ - ...prev, - { id: assistantId, role: "assistant", content: "", isStreaming: true }, - ]); - setAgentState("streaming"); - - const isCloudAgent = settings.isSignedIn && settings.cloudAgentMode === "openwhispr"; - - try { - let fullContent = ""; - - const streamSource = isCloudAgent - ? ReasoningService.processTextStreamingCloud(llmMessages, { systemPrompt }) - : ReasoningService.processTextStreaming( - llmMessages, - settings.agentModel, - settings.agentProvider, - { systemPrompt } - ); - - for await (const chunk of streamSource) { - fullContent += chunk; - setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) - ); + const handleTranscriptionComplete = useCallback( + async (text: string) => { + if (!text.trim()) return; + + if (!persistence.conversationId) { + await persistence.createConversation(t("agentMode.titleBar.newChat")); } - setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, isStreaming: false } : m)) - ); + const userMsg: Message = { + id: crypto.randomUUID(), + role: "user", + content: text, + isStreaming: false, + }; + setMessages((prev) => [...prev, userMsg]); + + await persistence.saveUserMessage(text); - if (conversationIdRef.current) { - window.electronAPI?.addAgentMessage?.(conversationIdRef.current, "assistant", fullContent); + if (persistence.conversationId && messages.length === 0) { + const title = text.slice(0, 50) + (text.length > 50 ? "..." : ""); + window.electronAPI?.updateAgentConversationTitle?.(persistence.conversationId, title); } - } catch (error) { - setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { ...m, content: `Error: ${(error as Error).message}`, isStreaming: false } - : m - ) - ); - } - - setAgentState("idle"); - }, []); + + await streaming.sendToAI(text, [...messages, userMsg]); + }, + [t, messages, setMessages, persistence, streaming] + ); useEffect(() => { const am = new AudioManager(); am.setSkipReasoning(true); am.setContext("agent"); am.setCallbacks({ - onStateChange: ({ - isRecording, - isProcessing, - }: { - isRecording: boolean; - isProcessing: boolean; - }) => { - if (isRecording) setAgentState("listening"); - else if (isProcessing) setAgentState("transcribing"); - }, + onStateChange: () => {}, onError: (error: { message?: string }) => { const msg = error?.message || (typeof error === "string" ? error : "Transcription failed"); - addSystemMessage(`Error: ${msg}`); - setAgentState("idle"); + addSystemMessage(`${t("agentMode.chat.errorPrefix")}: ${msg}`); }, onTranscriptionComplete: (result: { text: string }) => { handleTranscriptionComplete(result.text); @@ -163,8 +93,10 @@ export default function AgentOverlay() { }); audioManagerRef.current = am; return () => { + am.cleanup?.(); window.removeEventListener("api-key-changed", (am as any)._onApiKeyChanged); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [addSystemMessage, handleTranscriptionComplete]); const handleResizeStart = useCallback((e: React.MouseEvent, direction: string) => { @@ -245,11 +177,10 @@ export default function AgentOverlay() { }, []); const handleNewChat = useCallback(() => { - setMessages([]); - setAgentState("idle"); + persistenceNewChat(); setPartialTranscript(""); - conversationIdRef.current = null; - }, []); + streaming.cancelStream(); + }, [persistenceNewChat, streaming]); const handleClose = useCallback(() => { window.electronAPI?.hideAgentOverlay?.(); @@ -268,10 +199,15 @@ export default function AgentOverlay() { > - + - {/* Resize handles — edges */} + {/* Resize handles -- edges */}
handleResizeStart(e, "n")} @@ -289,7 +225,7 @@ export default function AgentOverlay() { onMouseDown={(e) => handleResizeStart(e, "e")} /> - {/* Resize handles — corners */} + {/* Resize handles -- corners */}
handleResizeStart(e, "nw")} diff --git a/src/components/CommandSearch.tsx b/src/components/CommandSearch.tsx index 15bd3b140..3dfdfd98b 100644 --- a/src/components/CommandSearch.tsx +++ b/src/components/CommandSearch.tsx @@ -1,22 +1,32 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { Search, FileText, Mic, Folder, Users, Upload } from "lucide-react"; +import { Search, FileText, Mic, Folder, Users, Upload, MessageSquare } from "lucide-react"; import { cn } from "./lib/utils"; import type { NoteItem, FolderItem, TranscriptionItem } from "../types/electron.js"; import { normalizeDbDate } from "../utils/dateFormatting"; +interface ConversationResult { + id: number; + title: string; + last_message?: string; + updated_at: string; +} + export interface CommandSearchProps { open: boolean; onOpenChange: (open: boolean) => void; - transcriptions: TranscriptionItem[]; - onNoteSelect: (noteId: number) => void; - onTranscriptSelect: (transcriptId: number) => void; + mode?: "all" | "conversations"; + transcriptions?: TranscriptionItem[]; + onNoteSelect?: (noteId: number) => void; + onTranscriptSelect?: (transcriptId: number) => void; + onConversationSelect?: (conversationId: number) => void; } type FlatItem = | { kind: "note"; note: NoteItem } - | { kind: "transcript"; transcript: TranscriptionItem }; + | { kind: "transcript"; transcript: TranscriptionItem } + | { kind: "conversation"; conversation: ConversationResult }; function relativeTime( dateStr: string, @@ -47,60 +57,115 @@ function stripMarkdownPreview(text: string): string { export default function CommandSearch({ open, onOpenChange, - transcriptions, + mode = "all", + transcriptions = [], onNoteSelect, onTranscriptSelect, + onConversationSelect, }: CommandSearchProps) { const { t } = useTranslation(); const [query, setQuery] = useState(""); const [notes, setNotes] = useState([]); const [folders, setFolders] = useState([]); + const [conversations, setConversations] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); const searchTimerRef = useRef | null>(null); + const searchVersionRef = useRef(0); + const isConversationsMode = mode === "conversations"; useEffect(() => { + if (isConversationsMode) return; window.electronAPI .getFolders() .then(setFolders) .catch(() => {}); - }, []); + }, [isConversationsMode]); useEffect(() => { if (!open) return; setQuery(""); setSelectedIndex(0); - window.electronAPI - .getNotes() - .then(setNotes) - .catch(() => {}); - }, [open]); - - useEffect(() => { - if (searchTimerRef.current) clearTimeout(searchTimerRef.current); - - if (!query.trim()) { + if (isConversationsMode) { + window.electronAPI?.getAgentConversationsWithPreview?.(20, 0, false).then((r) => { + if (r) + setConversations( + r.map((c) => ({ + id: c.id, + title: c.title || "Untitled", + last_message: c.last_message, + updated_at: c.updated_at, + })) + ); + }); + } else { window.electronAPI .getNotes() .then(setNotes) .catch(() => {}); - return; } + }, [open, isConversationsMode]); - searchTimerRef.current = setTimeout(async () => { - try { - const results = await window.electronAPI.searchNotes(query); - setNotes(results); - } catch { - // keep current results + useEffect(() => { + if (searchTimerRef.current) clearTimeout(searchTimerRef.current); + const version = ++searchVersionRef.current; + + if (isConversationsMode) { + if (!query.trim()) { + window.electronAPI?.getAgentConversationsWithPreview?.(20, 0, false).then((r) => { + if (searchVersionRef.current === version && r) { + setConversations( + r.map((c) => ({ + id: c.id, + title: c.title || "Untitled", + last_message: c.last_message, + updated_at: c.updated_at, + })) + ); + } + }); + return; + } + searchTimerRef.current = setTimeout(async () => { + try { + const r = await window.electronAPI?.semanticSearchConversations?.(query, 20); + if (searchVersionRef.current === version && r) { + setConversations( + r.map((c) => ({ + id: c.id, + title: c.title || "Untitled", + last_message: c.last_message, + updated_at: c.updated_at, + })) + ); + } + } catch { + /* keep current */ + } + }, 200); + } else { + if (!query.trim()) { + window.electronAPI + .getNotes() + .then(setNotes) + .catch(() => {}); + return; } - }, 200); + searchTimerRef.current = setTimeout(async () => { + try { + const results = await window.electronAPI.searchNotes(query); + setNotes(results); + } catch { + /* keep current */ + } + }, 200); + } return () => { if (searchTimerRef.current) clearTimeout(searchTimerRef.current); }; - }, [query]); + }, [query, isConversationsMode]); useEffect(() => { setSelectedIndex(0); @@ -139,21 +204,25 @@ export default function CommandSearch({ }, [transcriptions, query]); const flatItems = useMemo(() => { + if (isConversationsMode) { + return conversations.map((c) => ({ kind: "conversation" as const, conversation: c })); + } const items: FlatItem[] = []; for (const group of noteGroups) { for (const note of group.items) items.push({ kind: "note", note }); } for (const transcript of filteredTranscripts) items.push({ kind: "transcript", transcript }); return items; - }, [noteGroups, filteredTranscripts]); + }, [noteGroups, filteredTranscripts, conversations, isConversationsMode]); const selectItem = useCallback( (item: FlatItem) => { - if (item.kind === "note") onNoteSelect(item.note.id); - else onTranscriptSelect(item.transcript.id); + if (item.kind === "note") onNoteSelect?.(item.note.id); + else if (item.kind === "transcript") onTranscriptSelect?.(item.transcript.id); + else if (item.kind === "conversation") onConversationSelect?.(item.conversation.id); onOpenChange(false); }, - [onNoteSelect, onTranscriptSelect, onOpenChange] + [onNoteSelect, onTranscriptSelect, onConversationSelect, onOpenChange] ); const handleKeyDown = useCallback( @@ -211,7 +280,7 @@ export default function CommandSearch({ value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={handleKeyDown} - placeholder={t("commandSearch.placeholder")} + placeholder={isConversationsMode ? t("chat.search") : t("commandSearch.placeholder")} autoFocus className="flex-1 text-sm text-foreground placeholder:text-muted-foreground/40" style={{ @@ -237,9 +306,48 @@ export default function CommandSearch({ {!hasResults ? (

- {query.trim() ? t("commandSearch.noResults") : t("commandSearch.emptyState")} + {query.trim() + ? t("commandSearch.noResults") + : isConversationsMode + ? t("chat.noConversations") + : t("commandSearch.emptyState")}

+ ) : isConversationsMode ? ( + conversations.map((conv, idx) => ( + + )) ) : ( <> {noteGroups.length > 0 && ( diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx index 0511570b0..02426399f 100644 --- a/src/components/ControlPanel.tsx +++ b/src/components/ControlPanel.tsx @@ -34,6 +34,7 @@ const PersonalNotesView = React.lazy(() => import("./notes/PersonalNotesView")); const DictionaryView = React.lazy(() => import("./DictionaryView")); const UploadAudioView = React.lazy(() => import("./notes/UploadAudioView")); const IntegrationsView = React.lazy(() => import("./IntegrationsView")); +const ChatView = React.lazy(() => import("./chat/ChatView")); const CommandSearch = React.lazy(() => import("./CommandSearch")); export default function ControlPanel() { @@ -109,6 +110,9 @@ export default function ControlPanel() { if (mod && e.key === "k") { e.preventDefault(); setShowSearch(true); + } else if (mod && e.key === ",") { + e.preventDefault(); + setShowSettings(true); } }; window.addEventListener("keydown", handleKeyDown); @@ -227,6 +231,21 @@ export default function ControlPanel() { return () => cleanup?.(); }, []); + useEffect(() => { + const cleanup = window.electronAPI?.onNavigateToNote?.((data) => { + setActiveNoteId(data.noteId); + setActiveView("personal-notes"); + }); + return () => cleanup?.(); + }, []); + + useEffect(() => { + const cleanup = window.electronAPI?.onShowSettings?.(() => { + setShowSettings(true); + }); + return () => cleanup?.(); + }, []); + // When accessibility is missing on macOS, open the permissions settings page useEffect(() => { const cleanup = window.electronAPI?.onAccessibilityMissing?.(() => { @@ -735,6 +754,11 @@ export default function ControlPanel() { }} /> )} + {activeView === "chat" && ( + + + + )} {activeView === "personal-notes" && ( setShowSearch(true)} meetingRecordingRequest={meetingRecordingRequest} onMeetingRecordingRequestHandled={handleMeetingRecordingRequestHandled} isMeetingMode={isMeetingMode} diff --git a/src/components/ControlPanelSidebar.tsx b/src/components/ControlPanelSidebar.tsx index 2c092cb0b..d1c37e444 100644 --- a/src/components/ControlPanelSidebar.tsx +++ b/src/components/ControlPanelSidebar.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import { Home, + MessageSquare, NotebookPen, BookOpen, Upload, @@ -20,7 +21,13 @@ import { getCachedPlatform } from "../utils/platform"; const platform = getCachedPlatform(); -export type ControlPanelView = "home" | "personal-notes" | "dictionary" | "upload" | "integrations"; +export type ControlPanelView = + | "home" + | "chat" + | "personal-notes" + | "dictionary" + | "upload" + | "integrations"; interface ControlPanelSidebarProps { activeView: ControlPanelView; @@ -78,6 +85,7 @@ export default function ControlPanelSidebar({ icon: React.ComponentType<{ size?: number; className?: string }>; }[] = [ { id: "home", label: t("sidebar.home"), icon: Home }, + { id: "chat", label: t("sidebar.chat"), icon: MessageSquare }, { id: "personal-notes", label: t("sidebar.notes"), icon: NotebookPen }, { id: "upload", label: t("sidebar.upload"), icon: Upload }, { id: "dictionary", label: t("sidebar.dictionary"), icon: BookOpen }, @@ -95,7 +103,7 @@ export default function ControlPanelSidebar({
- - -
-
-
- ); -} diff --git a/src/components/ReasoningModelSelector.tsx b/src/components/ReasoningModelSelector.tsx index 3e75b80d3..57bac5dc8 100644 --- a/src/components/ReasoningModelSelector.tsx +++ b/src/components/ReasoningModelSelector.tsx @@ -824,7 +824,7 @@ export default function ReasoningModelSelector({

{t("reasoning.custom.queryPrefix")}{" "} - + {hasCustomBase ? `${effectiveReasoningBase}/models` : `${defaultOpenAIBase}/models`} diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index 8ac7c4679..226621e35 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -74,6 +74,7 @@ import { useTheme } from "../hooks/useTheme"; import type { GpuDevice, LocalTranscriptionProvider } from "../types/electron"; import logger from "../utils/logger"; import { SettingsRow } from "./ui/SettingsSection"; +import { useSettingsLayout } from "./ui/SidebarModal"; import { useUsage } from "../hooks/useUsage"; import { cn } from "./lib/utils"; import { startMigration, useMigration } from "../stores/noteStore.js"; @@ -137,7 +138,11 @@ function SettingsPanelRow({ children: React.ReactNode; className?: string; }) { - return

{children}
; + const { isCompact } = useSettingsLayout(); + + return ( +
{children}
+ ); } function SectionHeader({ title, description }: { title: string; description?: string }) { @@ -637,11 +642,13 @@ function GpuDeviceSelector({ purpose }: { purpose: "transcription" | "intelligen Promise.all([ window.electronAPI?.listGpus?.() ?? Promise.resolve([]), window.electronAPI?.getGpuDeviceIndex?.(purpose) ?? Promise.resolve("0"), - ]).then(([gpuList, idx]) => { - setGpus(gpuList); - setSelectedIndex(idx); - setLoaded(true); - }).catch(() => setLoaded(true)); + ]) + .then(([gpuList, idx]) => { + setGpus(gpuList); + setSelectedIndex(idx); + setLoaded(true); + }) + .catch(() => setLoaded(true)); }, [purpose]); if (!loaded || gpus.length < 2) return null; @@ -670,7 +677,18 @@ function GpuDeviceSelector({ purpose }: { purpose: "transcription" | "intelligen ))} - + + + @@ -679,6 +697,7 @@ function GpuDeviceSelector({ purpose }: { purpose: "transcription" | "intelligen } export default function SettingsPage({ activeSection = "general" }: SettingsPageProps) { + const { isCompact } = useSettingsLayout(); const { confirmDialog, alertDialog, @@ -768,6 +787,10 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage setDataRetentionEnabled, customDictionary, setCustomDictionary, + noteFilesEnabled, + setNoteFilesEnabled, + noteFilesPath, + setNoteFilesPath, } = useSettings(); const agentKey = useSettingsStore((s) => s.agentKey); @@ -1008,6 +1031,40 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage } }; + const [noteFilesDefaultPath, setNoteFilesDefaultPath] = useState(""); + const [noteFilesRebuilding, setNoteFilesRebuilding] = useState(false); + + useEffect(() => { + if (!noteFilesEnabled) return; + window.electronAPI?.noteFilesGetDefaultPath?.().then((p) => { + if (p) setNoteFilesDefaultPath(p); + }); + }, [noteFilesEnabled]); + + const handleNoteFilesToggle = useCallback( + async (enabled: boolean) => { + setNoteFilesEnabled(enabled); + await window.electronAPI?.noteFilesSetEnabled?.(enabled, noteFilesPath || undefined); + }, + [setNoteFilesEnabled, noteFilesPath] + ); + + const handleNoteFilesChangePath = useCallback(async () => { + const result = await window.electronAPI?.noteFilesPickFolder?.(); + if (result?.canceled || !result?.path) return; + setNoteFilesPath(result.path); + await window.electronAPI?.noteFilesSetPath?.(result.path); + }, [setNoteFilesPath]); + + const handleNoteFilesRebuild = useCallback(async () => { + setNoteFilesRebuilding(true); + try { + await window.electronAPI?.noteFilesRebuild?.(); + } finally { + setNoteFilesRebuilding(false); + } + }, []); + useEffect(() => { let mounted = true; @@ -1454,7 +1511,7 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage ) : isLoaded ? ( <> -
+
{/* Free */}
!open && setSwitchPreview(null)} > - + {t("settingsPage.account.pricing.confirmSwitch.title")} @@ -2212,6 +2269,60 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage
+ {/* Save Notes as Files */} +
+ + + + + + + + {noteFilesEnabled && ( + <> + + + + + + + + + + + + )} + +
+ {/* Floating Icon */}
)} -
); @@ -3451,7 +3561,6 @@ EOF`,
)}
- ); diff --git a/src/components/UpcomingMeetings.tsx b/src/components/UpcomingMeetings.tsx index ac5599a50..36b2866b3 100644 --- a/src/components/UpcomingMeetings.tsx +++ b/src/components/UpcomingMeetings.tsx @@ -104,7 +104,7 @@ export default function UpcomingMeetings({ events, isLoading }: UpcomingMeetings {/* Empty state */} {!isLoading && events.length === 0 && (
- {systemAudio.isMacOS && systemAudio.status === "denied" ? ( + {systemAudio.mode === "native" && !systemAudio.granted ? ( <>

@@ -113,7 +113,7 @@ export default function UpcomingMeetings({ events, isLoading }: UpcomingMeetings - - - - {isStreaming && ( - - )} -

- - ); -} +export { ChatMessage as AgentMessage } from "../chat/ChatMessage"; diff --git a/src/components/agent/AgentTitleBar.tsx b/src/components/agent/AgentTitleBar.tsx index 7409a4e6e..d6e868376 100644 --- a/src/components/agent/AgentTitleBar.tsx +++ b/src/components/agent/AgentTitleBar.tsx @@ -1,6 +1,7 @@ import { Plus, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "../lib/utils"; +import { getAgentName } from "../../utils/agentName"; interface AgentTitleBarProps { onNewChat: () => void; @@ -9,18 +10,21 @@ interface AgentTitleBarProps { export function AgentTitleBar({ onNewChat, onClose }: AgentTitleBarProps) { const { t } = useTranslation(); + const agentName = getAgentName(); return (
- {t("agentMode.titleBar.label")} + {agentName}
void; + onCancel?: () => void; + showHotkey?: boolean; + autoFocus?: boolean; +} + +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function HotkeyKeys({ hotkey }: { hotkey: string }) { + const label = formatHotkeyLabel(hotkey); + + if (isGlobeLikeHotkey(hotkey) || !label.includes("+")) { + return {label}; + } + + const parts = label.split("+"); + + return ( + + {parts.map((part, i) => ( + {part} + ))} + + ); +} + +function RecordingIndicator() { + return ( +
+
+
+
+ ); +} + +function ProcessingIndicator() { + return ( +
+
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+
+ ); +} + +export function ChatInput({ + agentState, + partialTranscript, + onTextSubmit, + onCancel, + showHotkey = false, + autoFocus = false, +}: ChatInputProps) { + const { t } = useTranslation(); + const agentKey = useSettingsStore((s) => s.agentKey); + const [inputText, setInputText] = useState(""); + const inputRef = useRef(null); + + const handleSubmit = useCallback(() => { + const text = inputText.trim(); + if (!text || !onTextSubmit) return; + onTextSubmit(text); + setInputText(""); + requestAnimationFrame(() => inputRef.current?.focus()); + }, [inputText, onTextSubmit]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit] + ); + + const isIdle = agentState === "idle"; + const isListening = agentState === "listening"; + const isTranscribing = agentState === "transcribing"; + const isBusy = + agentState === "thinking" || agentState === "streaming" || agentState === "tool-executing"; + + useEffect(() => { + if (isIdle) { + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [isIdle]); + + return ( +
+
+ {isListening && ( + <> + + + {partialTranscript || t("agentMode.input.listening")} + + + )} + + {isTranscribing && ( + <> + + + {t("agentMode.input.transcribing")} + + + )} + + {(isIdle || isBusy) && ( +
+ setInputText(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isBusy} + autoFocus={autoFocus} + placeholder={t("agentMode.input.typeMessage")} + className={cn( + "input-inline flex-1 outline-none bg-transparent", + "text-[13px] text-foreground placeholder:text-muted-foreground/40", + "min-w-0 p-0", + isBusy && "text-muted-foreground/30 cursor-not-allowed" + )} + /> + {isBusy && onCancel ? ( + + ) : isIdle ? ( +
+ {showHotkey && ( +
+
+ +
+ +
+ )} + +
+ ) : null} +
+ )} +
+
+ ); +} diff --git a/src/components/chat/ChatMessage.tsx b/src/components/chat/ChatMessage.tsx new file mode 100644 index 000000000..d5725b0aa --- /dev/null +++ b/src/components/chat/ChatMessage.tsx @@ -0,0 +1,284 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Copy, + Check, + Search, + FileText, + ChevronDown, + ChevronRight, + CircleAlert, +} from "lucide-react"; +import { cn } from "../lib/utils"; +import { MarkdownRenderer } from "../ui/MarkdownRenderer"; +import type { ToolCallInfo } from "./types"; +import { toolIcons } from "./toolIcons"; + +interface ChatMessageProps { + role: "user" | "assistant"; + content: string; + isStreaming: boolean; + toolCalls?: ToolCallInfo[]; +} + +const NOTE_TOOLS = new Set(["create_note", "update_note", "get_note"]); + +function ToolCallStep({ toolCall }: { toolCall: ToolCallInfo }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const Icon = toolIcons[toolCall.name] || Search; + const isExecuting = toolCall.status === "executing"; + const isError = toolCall.status === "error"; + const isCompleted = toolCall.status === "completed"; + const isClipboard = toolCall.name === "copy_to_clipboard" && isCompleted; + + const resultLines = toolCall.result?.split("\n") ?? []; + const hasDetail = resultLines.length > 1 && !isClipboard; + + return ( +
+ {isExecuting && ( +
+ )} + +
setExpanded((v) => !v) : undefined} + > + + + {isExecuting ? ( + + {t(`agentMode.tools.${toolCall.name}Status`, { defaultValue: toolCall.name })} + + ) : isError ? ( +
+ + + {toolCall.result || toolCall.name} + +
+ ) : isClipboard ? ( +
+ + {t("agentMode.tools.copiedToClipboard")} + + +
+ ) : ( + + {toolCall.result || toolCall.name} + + )} + + {hasDetail && !isExecuting && ( + + )} +
+ + {hasDetail && !isExecuting && ( +
+
+            {toolCall.result}
+          
+
+ )} +
+ ); +} + +function NoteCard({ noteId, title }: { noteId: number; title: string }) { + const { t } = useTranslation(); + + return ( + + ); +} + +function extractNoteCards(toolCalls?: ToolCallInfo[]): Array<{ noteId: number; title: string }> { + if (!toolCalls) return []; + const cards: Array<{ noteId: number; title: string }> = []; + const seen = new Set(); + + for (const tc of toolCalls) { + if (tc.status !== "completed" || !NOTE_TOOLS.has(tc.name) || !tc.metadata?.id) continue; + const noteId = Number(tc.metadata.id); + if (seen.has(noteId)) continue; + seen.add(noteId); + const title = + (tc.metadata.title as string) || + tc.result?.replace(/^(Created|Updated|Retrieved) note: "(.+)"$/, "$2") || + "Note"; + cards.push({ noteId, title }); + } + return cards; +} + +export function ChatMessage({ role, content, isStreaming, toolCalls }: ChatMessageProps) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // clipboard unavailable + } + }; + + if (role === "user") { + return ( +
+
+ {content} +
+
+ ); + } + + const hasToolCalls = toolCalls && toolCalls.length > 0; + const hasContent = content.length > 0; + const noteCards = extractNoteCards(toolCalls); + + return ( +
+
+ {hasToolCalls && ( +
0) && "mb-2 pb-1.5 border-b border-border/15" + )} + > + {toolCalls.map((tc) => ( + + ))} +
+ )} + + {hasContent && ( + + )} + + {isStreaming && hasContent && ( + + )} + + {isStreaming && !hasContent && !hasToolCalls && ( + + {t("agentMode.input.thinking")}... + + )} + + {noteCards.length > 0 && !isStreaming && ( +
+ {noteCards.map((card) => ( + + ))} +
+ )} + + {hasContent && !isStreaming && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/components/chat/ChatMessages.tsx b/src/components/chat/ChatMessages.tsx new file mode 100644 index 000000000..297990eed --- /dev/null +++ b/src/components/chat/ChatMessages.tsx @@ -0,0 +1,42 @@ +import { useRef, useEffect } from "react"; +import { cn } from "../lib/utils"; +import { ChatMessage } from "./ChatMessage"; +import type { Message } from "./types"; + +interface ChatMessagesProps { + messages: Message[]; + emptyState?: React.ReactNode; +} + +export function ChatMessages({ messages, emptyState }: ChatMessagesProps) { + const scrollRef = useRef(null); + + useEffect(() => { + const el = scrollRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + } + }, [messages]); + + return ( +
+ {messages.length === 0 ? ( + (emptyState ?? null) + ) : ( +
+ {messages + .filter((msg) => msg.role !== "tool") + .map((msg) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx new file mode 100644 index 000000000..a65e656f1 --- /dev/null +++ b/src/components/chat/ChatView.tsx @@ -0,0 +1,222 @@ +import { useState, useCallback, useEffect, lazy, Suspense } from "react"; +import { useTranslation } from "react-i18next"; +import { useChatPersistence } from "./useChatPersistence"; +import { useChatStreaming } from "./useChatStreaming"; +import { ChatMessages } from "./ChatMessages"; +import { ChatInput } from "./ChatInput"; +import ConversationList from "./ConversationList"; +import EmptyChatState from "./EmptyChatState"; +import { ConfirmDialog } from "../ui/dialog"; +import { useDialogs } from "../../hooks/useDialogs"; +import { getCachedPlatform } from "../../utils/platform"; + +const CommandSearch = lazy(() => import("../CommandSearch")); + +const platform = getCachedPlatform(); + +function NewChatEmptyState() { + const { t } = useTranslation(); + return ( +
+ + + + + + + + + +

+ {t("chat.newChatEmpty")} +

+
+ ); +} + +export default function ChatView() { + const { t } = useTranslation(); + const [activeConversationId, setActiveConversationId] = useState(null); + const [isNewChat, setIsNewChat] = useState(true); + const [refreshKey, setRefreshKey] = useState(0); + const [showSearch, setShowSearch] = useState(false); + const { confirmDialog, showConfirmDialog, hideConfirmDialog } = useDialogs(); + + const persistence = useChatPersistence({ + conversationId: activeConversationId, + onConversationCreated: (id) => { + setActiveConversationId(id); + setRefreshKey((k) => k + 1); + }, + }); + + const streaming = useChatStreaming({ + messages: persistence.messages, + setMessages: persistence.setMessages, + onStreamComplete: (_id, content, toolCalls) => { + persistence.saveAssistantMessage(content, toolCalls); + }, + }); + + const handleSelectConversation = useCallback( + async (id: number) => { + if (id === activeConversationId) return; + setActiveConversationId(id); + setIsNewChat(false); + await persistence.loadConversation(id); + }, + [activeConversationId, persistence] + ); + + const handleNewChat = useCallback(() => { + setActiveConversationId(null); + setIsNewChat(true); + persistence.handleNewChat(); + }, [persistence]); + + const handleTextSubmit = useCallback( + async (text: string) => { + setIsNewChat(false); + let convId = activeConversationId; + if (!convId) { + const title = text.length > 50 ? `${text.slice(0, 50)}...` : text; + convId = await persistence.createConversation(title); + } + + const userMsg = { + id: crypto.randomUUID(), + role: "user" as const, + content: text, + isStreaming: false, + }; + persistence.setMessages((prev) => [...prev, userMsg]); + await persistence.saveUserMessage(text); + + const allMessages = [...persistence.messages, userMsg]; + await streaming.sendToAI(text, allMessages); + }, + [activeConversationId, persistence, streaming] + ); + + const handleArchive = useCallback( + async (id: number) => { + await window.electronAPI?.archiveAgentConversation?.(id); + if (activeConversationId === id) { + handleNewChat(); + } + setRefreshKey((k) => k + 1); + }, + [activeConversationId, handleNewChat] + ); + + const handleDelete = useCallback( + (id: number) => { + showConfirmDialog({ + title: t("chat.delete"), + description: t("chat.deleteConfirm"), + onConfirm: async () => { + await window.electronAPI?.deleteAgentConversation?.(id); + if (activeConversationId === id) { + handleNewChat(); + } + setRefreshKey((k) => k + 1); + }, + variant: "destructive", + }); + }, + [activeConversationId, handleNewChat, showConfirmDialog, t] + ); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const mod = platform === "darwin" ? e.metaKey : e.ctrlKey; + if (mod && e.key === "n") { + e.preventDefault(); + handleNewChat(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleNewChat]); + + const hasActiveChat = + activeConversationId !== null || persistence.messages.length > 0 || isNewChat; + + return ( + <> + + {showSearch && ( + + + + )} +
+
+ setShowSearch(true)} + onArchive={handleArchive} + onDelete={handleDelete} + refreshKey={refreshKey} + /> +
+
+ {hasActiveChat ? ( + <> + } /> + + + ) : ( + + )} +
+
+ + ); +} diff --git a/src/components/chat/ConversationDateGroup.tsx b/src/components/chat/ConversationDateGroup.tsx new file mode 100644 index 000000000..b73c720dc --- /dev/null +++ b/src/components/chat/ConversationDateGroup.tsx @@ -0,0 +1,19 @@ +import { cn } from "../lib/utils"; + +interface ConversationDateGroupProps { + label: string; +} + +export default function ConversationDateGroup({ label }: ConversationDateGroupProps) { + return ( +
+ {label} +
+ ); +} diff --git a/src/components/chat/ConversationItem.tsx b/src/components/chat/ConversationItem.tsx new file mode 100644 index 000000000..591519c3a --- /dev/null +++ b/src/components/chat/ConversationItem.tsx @@ -0,0 +1,131 @@ +import { useTranslation } from "react-i18next"; +import { MoreHorizontal, Archive, ArchiveRestore, Trash2 } from "lucide-react"; +import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from "../ui/dropdown-menu"; +import { cn } from "../lib/utils"; +import { normalizeDbDate } from "../../utils/dateFormatting"; + +export interface ConversationPreview { + id: number; + title: string; + preview?: string; + created_at: string; + updated_at: string; + is_archived?: boolean; +} + +interface ConversationItemProps { + conversation: ConversationPreview; + isActive: boolean; + onClick: () => void; + onArchive: (id: number) => void; + onDelete: (id: number) => void; +} + +function formatTimestamp(dateStr: string): string { + const date = normalizeDbDate(dateStr); + if (Number.isNaN(date.getTime())) return ""; + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (seconds < 60) return "<1m"; + if (minutes < 60) return `${minutes}m`; + if (hours < 24) return `${hours}h`; + if (days < 7) return `${days}d`; + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +export default function ConversationItem({ + conversation, + isActive, + onClick, + onArchive, + onDelete, +}: ConversationItemProps) { + const { t } = useTranslation(); + const isArchived = !!conversation.is_archived; + + return ( + + + + { + e.stopPropagation(); + onArchive(conversation.id); + }} + className="text-xs gap-2 rounded-lg px-2.5 py-1.5 cursor-pointer focus:bg-foreground/5" + > + {isArchived ? ( + <> + + {t("chat.unarchive")} + + ) : ( + <> + + {t("chat.archive")} + + )} + + + { + e.stopPropagation(); + onDelete(conversation.id); + }} + className="text-xs gap-2 rounded-lg px-2.5 py-1.5 text-destructive focus:text-destructive focus:bg-destructive/10 cursor-pointer" + > + + {t("chat.delete")} + + + +
+
+ {conversation.preview && ( +

+ {conversation.preview} +

+ )} +
+ + ); +} diff --git a/src/components/chat/ConversationList.tsx b/src/components/chat/ConversationList.tsx new file mode 100644 index 000000000..df95768ff --- /dev/null +++ b/src/components/chat/ConversationList.tsx @@ -0,0 +1,308 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { SquarePen, Search, Archive as ArchiveIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { cn } from "../lib/utils"; +import { normalizeDbDate } from "../../utils/dateFormatting"; +import ConversationItem, { type ConversationPreview } from "./ConversationItem"; +import ConversationDateGroup from "./ConversationDateGroup"; +import EmptyConversationList from "./EmptyConversationList"; + +type FlatItem = + | { type: "header"; label: string } + | { type: "conversation"; data: ConversationPreview }; + +interface ConversationListProps { + activeConversationId: number | null; + onSelectConversation: (id: number) => void; + onNewChat: () => void; + onOpenSearch: () => void; + onArchive: (id: number) => void; + onDelete: (id: number) => void; + refreshKey: number; +} + +function groupByDate(conversations: ConversationPreview[], t: (key: string) => string): FlatItem[] { + if (conversations.length === 0) return []; + + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + + const items: FlatItem[] = []; + let currentGroup: string | null = null; + + for (const conv of conversations) { + const date = normalizeDbDate(conv.updated_at); + const target = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + + let group: string; + if (target.getTime() >= today.getTime()) { + group = t("chat.today"); + } else if (target.getTime() >= yesterday.getTime()) { + group = t("chat.yesterday"); + } else if (target.getTime() >= weekAgo.getTime()) { + group = t("chat.previousWeek"); + } else { + group = t("chat.older"); + } + + if (group !== currentGroup) { + items.push({ type: "header", label: group }); + currentGroup = group; + } + items.push({ type: "conversation", data: conv }); + } + + return items; +} + +function SkeletonRows() { + return ( +
+ {[0, 1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ ); +} + +export default function ConversationList({ + activeConversationId, + onSelectConversation, + onNewChat, + onOpenSearch, + onArchive, + onDelete, + refreshKey, +}: ConversationListProps) { + const { t } = useTranslation(); + const [conversations, setConversations] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showArchived, setShowArchived] = useState(false); + const scrollRef = useRef(null); + const showSkeletonTimer = useRef | null>(null); + const [showSkeleton, setShowSkeleton] = useState(false); + + const loadConversations = useCallback(async () => { + try { + const [active, archived] = await Promise.all([ + window.electronAPI?.getAgentConversationsWithPreview?.(200, 0, false), + window.electronAPI?.getAgentConversationsWithPreview?.(200, 0, true), + ]); + const toPreview = (c: { + id: number; + title: string; + last_message?: string; + created_at: string; + updated_at: string; + archived_at?: string; + }) => ({ + id: c.id, + title: c.title || "Untitled", + preview: c.last_message, + created_at: c.created_at, + updated_at: c.updated_at, + is_archived: !!c.archived_at, + }); + setConversations([...(active ?? []).map(toPreview), ...(archived ?? []).map(toPreview)]); + } catch { + // silently fail + } finally { + setIsLoading(false); + setShowSkeleton(false); + if (showSkeletonTimer.current) { + clearTimeout(showSkeletonTimer.current); + showSkeletonTimer.current = null; + } + } + }, []); + + useEffect(() => { + setIsLoading(true); + showSkeletonTimer.current = setTimeout(() => setShowSkeleton(true), 150); + loadConversations(); + return () => { + if (showSkeletonTimer.current) clearTimeout(showSkeletonTimer.current); + }; + }, [loadConversations, refreshKey]); + + const filtered = useMemo(() => { + return showArchived + ? conversations.filter((c) => c.is_archived) + : conversations.filter((c) => !c.is_archived); + }, [conversations, showArchived]); + + const flatItems = useMemo(() => groupByDate(filtered, t), [filtered, t]); + + const virtualizer = useVirtualizer({ + count: flatItems.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => (flatItems[index].type === "header" ? 28 : 52), + overscan: 5, + }); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (flatItems.length === 0) return; + + const convItems = flatItems + .map((item, index) => ({ item, index })) + .filter((entry) => entry.item.type === "conversation"); + if (convItems.length === 0) return; + + const currentIdx = convItems.findIndex( + (entry) => entry.item.type === "conversation" && entry.item.data.id === activeConversationId + ); + + if (e.key === "ArrowDown") { + e.preventDefault(); + const next = currentIdx < convItems.length - 1 ? currentIdx + 1 : 0; + const item = convItems[next].item; + if (item.type === "conversation") onSelectConversation(item.data.id); + virtualizer.scrollToIndex(convItems[next].index); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const prev = currentIdx > 0 ? currentIdx - 1 : convItems.length - 1; + const item = convItems[prev].item; + if (item.type === "conversation") onSelectConversation(item.data.id); + virtualizer.scrollToIndex(convItems[prev].index); + } else if (e.key === "Enter" && activeConversationId) { + e.preventDefault(); + } + }, + [flatItems, activeConversationId, onSelectConversation, virtualizer] + ); + + if (isLoading && showSkeleton) { + return ( +
+
+

{t("sidebar.chat")}

+
+ +
+ ); + } + + if (isLoading && !showSkeleton) { + return ( +
+
+

{t("sidebar.chat")}

+
+
+ ); + } + + return ( +
+
+ + + {conversations.some((c) => c.is_archived) && ( + + )} +
+ + {flatItems.length === 0 ? ( + + ) : ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const item = flatItems[virtualItem.index]; + return ( +
+ {item.type === "header" ? ( + + ) : ( + onSelectConversation(item.data.id)} + onArchive={onArchive} + onDelete={onDelete} + /> + )} +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/src/components/chat/EmptyChatState.tsx b/src/components/chat/EmptyChatState.tsx new file mode 100644 index 000000000..f7a9f0d0b --- /dev/null +++ b/src/components/chat/EmptyChatState.tsx @@ -0,0 +1,11 @@ +import { useTranslation } from "react-i18next"; + +export default function EmptyChatState() { + const { t } = useTranslation(); + + return ( +
+

{t("chat.selectChat")}

+
+ ); +} diff --git a/src/components/chat/EmptyConversationList.tsx b/src/components/chat/EmptyConversationList.tsx new file mode 100644 index 000000000..cf0fae566 --- /dev/null +++ b/src/components/chat/EmptyConversationList.tsx @@ -0,0 +1,29 @@ +import { Plus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "../lib/utils"; + +interface EmptyConversationListProps { + onNewChat: () => void; +} + +export default function EmptyConversationList({ onNewChat }: EmptyConversationListProps) { + const { t } = useTranslation(); + + return ( +
+

{t("chat.noConversations")}

+ +
+ ); +} diff --git a/src/components/chat/toolIcons.ts b/src/components/chat/toolIcons.ts new file mode 100644 index 000000000..3155c8da1 --- /dev/null +++ b/src/components/chat/toolIcons.ts @@ -0,0 +1,11 @@ +import { Search, Globe, ClipboardCheck, Calendar, FileText, FilePlus, FilePen } from "lucide-react"; + +export const toolIcons: Record = { + search_notes: Search, + web_search: Globe, + copy_to_clipboard: ClipboardCheck, + get_calendar_events: Calendar, + get_note: FileText, + create_note: FilePlus, + update_note: FilePen, +}; diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts new file mode 100644 index 000000000..987630295 --- /dev/null +++ b/src/components/chat/types.ts @@ -0,0 +1,26 @@ +export interface ToolCallInfo { + id: string; + name: string; + arguments: string; + status: "executing" | "completed" | "error"; + result?: string; + metadata?: Record; +} + +export interface Message { + id: string; + role: "user" | "assistant" | "tool"; + content: string; + isStreaming: boolean; + toolCalls?: ToolCallInfo[]; +} + +export type AgentState = + | "idle" + | "listening" + | "transcribing" + | "thinking" + | "streaming" + | "tool-executing"; + +export { toolIcons } from "./toolIcons"; diff --git a/src/components/chat/useChatPersistence.ts b/src/components/chat/useChatPersistence.ts new file mode 100644 index 000000000..1ed040015 --- /dev/null +++ b/src/components/chat/useChatPersistence.ts @@ -0,0 +1,94 @@ +import { useState, useRef, useCallback } from "react"; +import type { Message, ToolCallInfo } from "./types"; + +interface UseChatPersistenceOptions { + conversationId?: number | null; + onConversationCreated?: (id: number, title: string) => void; +} + +export interface ChatPersistence { + messages: Message[]; + setMessages: React.Dispatch>; + conversationId: number | null; + createConversation: (title: string) => Promise; + loadConversation: (id: number) => Promise; + saveUserMessage: (text: string) => Promise; + saveAssistantMessage: (content: string, toolCalls?: ToolCallInfo[]) => Promise; + handleNewChat: () => void; +} + +export function useChatPersistence(options: UseChatPersistenceOptions = {}): ChatPersistence { + const [messages, setMessages] = useState([]); + const conversationIdRef = useRef(options.conversationId ?? null); + + const createConversation = useCallback( + async (title: string): Promise => { + const conv = await window.electronAPI?.createAgentConversation?.(title); + const id = conv?.id ?? 0; + conversationIdRef.current = id; + options.onConversationCreated?.(id, title); + return id; + }, + [options] + ); + + const loadConversation = useCallback(async (id: number) => { + const conv = await window.electronAPI?.getAgentConversation?.(id); + if (!conv) return; + conversationIdRef.current = id; + const loaded: Message[] = conv.messages.map((m) => { + const parsed = m.metadata ? tryParseMetadata(m.metadata) : undefined; + const toolCalls = parsed?.toolCalls as ToolCallInfo[] | undefined; + return { + id: crypto.randomUUID(), + role: m.role as Message["role"], + content: m.content, + isStreaming: false, + ...(toolCalls ? { toolCalls } : {}), + }; + }); + setMessages(loaded); + }, []); + + const saveUserMessage = useCallback(async (text: string) => { + if (conversationIdRef.current) { + window.electronAPI?.addAgentMessage?.(conversationIdRef.current, "user", text); + } + }, []); + + const saveAssistantMessage = useCallback(async (content: string, toolCalls?: ToolCallInfo[]) => { + if (conversationIdRef.current) { + window.electronAPI?.addAgentMessage?.( + conversationIdRef.current, + "assistant", + content, + toolCalls?.length ? { toolCalls } : undefined + ); + } + }, []); + + const handleNewChat = useCallback(() => { + setMessages([]); + conversationIdRef.current = null; + }, []); + + return { + messages, + setMessages, + conversationId: conversationIdRef.current, + createConversation, + loadConversation, + saveUserMessage, + saveAssistantMessage, + handleNewChat, + }; +} + +function tryParseMetadata(raw: string | undefined): Record | undefined { + if (!raw) return undefined; + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} diff --git a/src/components/chat/useChatStreaming.ts b/src/components/chat/useChatStreaming.ts new file mode 100644 index 000000000..ee99bff30 --- /dev/null +++ b/src/components/chat/useChatStreaming.ts @@ -0,0 +1,291 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import ReasoningService, { type AgentStreamChunk } from "../../services/ReasoningService"; +import { getSettings } from "../../stores/settingsStore"; +import { getAgentSystemPrompt } from "../../config/prompts"; +import { createToolRegistry } from "../../services/tools"; +import type { ToolRegistry } from "../../services/tools/ToolRegistry"; +import type { Message, AgentState, ToolCallInfo } from "./types"; + +const RAG_NOTE_LIMIT = 5; +const RAG_NOTE_SNIPPET_LENGTH = 500; + +const LOCAL_TOOL_MIN_PARAMS_B = 4; + +function estimateModelSizeB(modelId: string): number { + const match = modelId.match(/-([\d.]+)[bB]/); + return match ? parseFloat(match[1]) : 0; +} + +async function buildRAGContext(userText: string): Promise { + if (!window.electronAPI?.semanticSearchNotes) return ""; + try { + const results = await window.electronAPI.semanticSearchNotes(userText, RAG_NOTE_LIMIT); + if (!results || results.length === 0) return ""; + + const snippets = await Promise.all( + results.map(async (r: { id: number; title: string; score?: number }) => { + const note = await window.electronAPI.getNote(r.id); + if (!note) return null; + const content = (note.content || "").slice(0, RAG_NOTE_SNIPPET_LENGTH); + return `\n${content}\n`; + }) + ); + + return snippets.filter(Boolean).join("\n\n"); + } catch { + return ""; + } +} + +interface UseChatStreamingOptions { + messages: Message[]; + setMessages: React.Dispatch>; + /** Optional note context to prepend to the system prompt (used by embedded note chat). */ + noteContext?: string; + onStreamComplete?: (assistantId: string, content: string, toolCalls?: ToolCallInfo[]) => void; +} + +export interface ChatStreaming { + agentState: AgentState; + toolStatus: string; + activeToolName: string; + sendToAI: (userText: string, allMessages: Message[]) => Promise; + cancelStream: () => void; +} + +export function useChatStreaming({ + messages, + setMessages, + noteContext: externalNoteContext, + onStreamComplete, +}: UseChatStreamingOptions): ChatStreaming { + const { t } = useTranslation(); + const [agentState, setAgentState] = useState("idle"); + const [toolStatus, setToolStatus] = useState(""); + const [activeToolName, setActiveToolName] = useState(""); + const mountedRef = useRef(true); + const messagesRef = useRef([]); + const noteContextRef = useRef(externalNoteContext); + noteContextRef.current = externalNoteContext; + const toolRegistryRef = useRef<{ key: string; registry: ToolRegistry } | null>(null); + + useEffect(() => { + messagesRef.current = messages; + }, [messages]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + ReasoningService.cancelActiveStream(); + }; + }, []); + + const cancelStream = useCallback(() => { + ReasoningService.cancelActiveStream(); + setAgentState("idle"); + setToolStatus(""); + setActiveToolName(""); + }, []); + + const sendToAI = useCallback( + async (userText: string, allMessages: Message[]) => { + setAgentState("thinking"); + + const settings = getSettings(); + const isCloudAgent = settings.isSignedIn && settings.cloudAgentMode === "openwhispr"; + const isLocalProvider = !["openai", "groq", "custom", "anthropic", "gemini"].includes( + settings.agentProvider + ); + const localModelCanUseTool = + isLocalProvider && estimateModelSizeB(settings.agentModel) >= LOCAL_TOOL_MIN_PARAMS_B; + const supportsTools = isCloudAgent || !isLocalProvider || localModelCanUseTool; + + let registry: ToolRegistry | null = null; + if (supportsTools) { + const cacheKey = `${settings.isSignedIn}-${settings.gcalConnected}-${settings.cloudBackupEnabled}`; + if (toolRegistryRef.current?.key === cacheKey) { + registry = toolRegistryRef.current.registry; + } else { + registry = createToolRegistry({ + isSignedIn: settings.isSignedIn, + gcalConnected: settings.gcalConnected, + cloudBackupEnabled: settings.cloudBackupEnabled, + }); + toolRegistryRef.current = { key: cacheKey, registry }; + } + } + + const ragContext = await buildRAGContext(userText); + const combinedContext = [noteContextRef.current, ragContext].filter(Boolean).join("\n\n"); + const systemPrompt = getAgentSystemPrompt( + registry?.getAll().map((t) => t.name), + combinedContext || undefined + ); + + const llmMessages = [ + { role: "system", content: systemPrompt }, + ...allMessages.slice(-20).map((m) => ({ role: m.role, content: m.content })), + ]; + + const assistantId = crypto.randomUUID(); + setMessages((prev) => [ + ...prev, + { id: assistantId, role: "assistant", content: "", isStreaming: true }, + ]); + setAgentState("streaming"); + + try { + let fullContent = ""; + let stream: AsyncGenerator; + + if (isCloudAgent) { + const executeToolCall = registry + ? async (name: string, argsJson: string) => { + const tool = registry.get(name); + if (!tool) + return { + data: `Unknown tool: ${name}`, + displayText: t("agentMode.tools.unknownTool", { name }), + }; + let args: Record; + try { + args = JSON.parse(argsJson); + } catch { + return { + data: `Invalid tool arguments for ${name}`, + displayText: t("agentMode.tools.invalidArgs", { name }), + }; + } + const result = await tool.execute(args); + const data = result.success + ? typeof result.data === "string" + ? result.data + : JSON.stringify(result.data) + : result.displayText; + const metadata = + result.success && result.data && typeof result.data === "object" + ? (result.data as Record) + : undefined; + return { data, displayText: result.displayText, metadata }; + } + : undefined; + + stream = ReasoningService.processTextStreamingCloud(llmMessages, { + systemPrompt, + tools: registry?.getAll().map((t) => ({ + name: t.name, + description: t.description, + parameters: t.parameters, + })), + executeToolCall, + }); + } else { + const aiTools = registry?.toAISDKFormat(); + stream = ReasoningService.processTextStreamingAI( + llmMessages, + settings.agentModel, + settings.agentProvider, + { systemPrompt }, + aiTools + ); + } + + for await (const chunk of stream) { + if (!mountedRef.current) { + ReasoningService.cancelActiveStream(); + break; + } + if (chunk.type === "content") { + fullContent += chunk.text; + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) + ); + } else if (chunk.type === "tool_calls") { + for (const call of chunk.calls) { + setAgentState("tool-executing"); + setActiveToolName(call.name); + setToolStatus( + t(`agentMode.tools.${call.name}Status`, { defaultValue: `Using ${call.name}...` }) + ); + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { + ...m, + toolCalls: [ + ...(m.toolCalls || []), + { + id: call.id, + name: call.name, + arguments: call.arguments, + status: "executing" as const, + }, + ], + } + : m + ) + ); + } + } else if (chunk.type === "tool_result") { + setMessages((prev) => + prev.map((m) => + m.id === assistantId && m.toolCalls + ? { + ...m, + toolCalls: m.toolCalls.map((tc) => + tc.id === chunk.callId + ? { + ...tc, + status: "completed" as const, + result: chunk.displayText, + ...(chunk.metadata ? { metadata: chunk.metadata } : {}), + } + : tc + ), + } + : m + ) + ); + setAgentState("streaming"); + setToolStatus(""); + setActiveToolName(""); + } + } + + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, isStreaming: false } : m)) + ); + + const finalMsg = messagesRef.current.find((m) => m.id === assistantId); + onStreamComplete?.(assistantId, fullContent, finalMsg?.toolCalls); + } catch (error) { + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { + ...m, + content: `${t("agentMode.chat.errorPrefix")}: ${(error as Error).message}`, + isStreaming: false, + } + : m + ) + ); + } + + setAgentState("idle"); + setToolStatus(""); + setActiveToolName(""); + }, + [t, setMessages, onStreamComplete] + ); + + return { + agentState, + toolStatus, + activeToolName, + sendToAI, + cancelStream, + }; +} diff --git a/src/components/notes/ActionManagerDialog.tsx b/src/components/notes/ActionManagerDialog.tsx index 1ec3166a6..5f1fc98cd 100644 --- a/src/components/notes/ActionManagerDialog.tsx +++ b/src/components/notes/ActionManagerDialog.tsx @@ -1,16 +1,11 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Sparkles, Pencil, Trash2, Loader2 } from "lucide-react"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"; +import { Sparkles, Trash2, Loader2, Plus, Zap } from "lucide-react"; +import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; import { Button } from "../ui/button"; +import { Input } from "../ui/input"; import { cn } from "../lib/utils"; -import { - useActions, - initializeActions, - getActionName, - getActionDescription, -} from "../../stores/actionStore"; -import { notesInputClass, notesTextareaClass } from "./shared"; +import { useActions, initializeActions, getActionName } from "../../stores/actionStore"; import type { ActionItem } from "../../types/electron"; interface ActionManagerDialogProps { @@ -25,16 +20,33 @@ export default function ActionManagerDialog({ open, onOpenChange }: ActionManage const [description, setDescription] = useState(""); const [prompt, setPrompt] = useState(""); const [editingId, setEditingId] = useState(null); + const [selectedId, setSelectedId] = useState(null); const [isSaving, setIsSaving] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const nameInputRef = useRef(null); useEffect(() => { if (open) { initializeActions(); - } else { + setIsCreating(false); + setEditingId(null); + setSelectedId(null); resetForm(); } }, [open]); + // Auto-select the first action when actions load and nothing is selected + useEffect(() => { + if (open && actions.length > 0 && selectedId === null && !isCreating) { + const first = actions[0]; + setSelectedId(first.id); + setEditingId(first.id); + setName(first.name); + setDescription(first.description); + setPrompt(first.prompt); + } + }, [open, actions, selectedId, isCreating]); + const resetForm = () => { setName(""); setDescription(""); @@ -42,16 +54,30 @@ export default function ActionManagerDialog({ open, onOpenChange }: ActionManage setEditingId(null); }; - const handleEdit = (action: ActionItem) => { + const handleSelectAction = (action: ActionItem) => { + setSelectedId(action.id); + setEditingId(action.id); setName(action.name); setDescription(action.description); setPrompt(action.prompt); - setEditingId(action.id); + setIsCreating(false); + }; + + const handleNewAction = () => { + resetForm(); + setSelectedId(null); + setIsCreating(true); + // Focus name input after state update + setTimeout(() => nameInputRef.current?.focus(), 50); }; const handleDelete = async (id: number) => { await window.electronAPI.deleteAction(id); - if (editingId === id) resetForm(); + if (selectedId === id) { + setSelectedId(null); + setIsCreating(false); + resetForm(); + } }; const handleSave = async () => { @@ -66,148 +92,245 @@ export default function ActionManagerDialog({ open, onOpenChange }: ActionManage }); } else { await window.electronAPI.createAction(name.trim(), description.trim(), prompt.trim()); + setIsCreating(false); } - resetForm(); } finally { setIsSaving(false); } }; + const showEditor = isCreating || selectedId !== null; + const selectedAction = actions.find((a) => a.id === selectedId); + const hasUnsavedChanges = isCreating + ? name.trim() !== "" || prompt.trim() !== "" + : selectedAction + ? name !== selectedAction.name || + description !== selectedAction.description || + prompt !== selectedAction.prompt + : false; + return ( - - - - - {t("notes.actions.manageTitle")} - - - -
-

- {editingId !== null ? t("notes.actions.editAction") : t("notes.actions.addAction")} -

- setName(e.target.value)} - placeholder={t("notes.actions.namePlaceholder")} - disabled={isSaving} - className={cn(notesInputClass, "disabled:opacity-40")} - /> - setDescription(e.target.value)} - placeholder={t("notes.actions.descriptionPlaceholder")} - disabled={isSaving} - className={cn(notesInputClass, "disabled:opacity-40")} - /> -