diff --git a/docs/latest-updates/commit-message.md b/docs/latest-updates/commit-message.md deleted file mode 100644 index 95e1d1a..0000000 --- a/docs/latest-updates/commit-message.md +++ /dev/null @@ -1,29 +0,0 @@ -feat(app): implement full productivity launcher with AI chat, OCR, and 6 plugins - -Historical commit-message draft for the early launch snapshot. - -Add complete desktop productivity launcher with Tauri v2 backend and React 19 frontend. - -Features implemented: -- AI chat with real SSE streaming for OpenAI, Google Gemini, Kimi/Moonshot, - and Anthropic Claude. Supports multi-turn conversation, up to 5 image - attachments (paste/file dialog), and Markdown rendering via react-markdown. -- Local OCR using Tesseract Rust crate (0.15). Triggered by Alt+O global - shortcut. Captures screen region, extracts text, copies to clipboard. -- Screenshot capture with Alt+S. Region selection overlay, Retina-aware - coordinate mapping, saves to Desktop and copies image to clipboard. -- 6 plugins: appLauncher (cross-platform), calculator, docker, webSearch, - fileSearch (fast + smart AI-powered with content reading), and translate - (quick translate + full translation UI). -- Smart file search with natural language queries, time filters, and AI - relevance ranking using configured provider. -- Settings with live API model fetching (24h cache), interactive shortcut - recording for 3 global shortcuts, and provider selection. -- Cross-platform app launching: macOS (.app), Windows (.lnk), Linux (.desktop). - -New components: MarkdownMessage, Tooltip, ShortcutRecorder, streaming utilities, -quickTranslate utility. - -New Rust commands: search_files, smart_search_files, open_file, open_app, -update_main_shortcut, update_screenshot_shortcut, update_ocr_shortcut, -open_image_dialog, close_selector, capture_region with OCR integration. diff --git a/docs/latest-updates/release-notes.md b/docs/latest-updates/release-notes.md deleted file mode 100644 index ec20ea0..0000000 --- a/docs/latest-updates/release-notes.md +++ /dev/null @@ -1,114 +0,0 @@ -# Release Notes: GQuick v0.1.0 — AI-Powered Productivity Launcher - -> Historical snapshot of the early launch release. It is kept for reference and does not describe current plugin counts or provider exposure. - -## Summary - -GQuick is a fully functional cross-platform desktop productivity launcher built with Tauri v2 and React 19. This release includes six search plugins, real AI chat with streaming and image support, local Tesseract OCR, screenshot capture, configurable global shortcuts, and a comprehensive settings panel — all accessible from a single keyboard-driven interface. - ---- - -## What's New - -### AI Chat with Real Streaming -- **Multi-provider support**: OpenAI (GPT), Google Gemini, Kimi/Moonshot, Anthropic Claude -- **Real-time SSE streaming**: Assistant responses appear word-by-word as they are generated -- **Image inputs**: Attach up to 5 images via paste, native file dialog, or drag-and-drop. Supported in chat for vision-capable models -- **Multi-turn conversations**: Full chat history context sent with each message -- **Markdown rendering**: Code blocks, tables, lists, bold/italic, blockquotes, and inline code styled for dark mode - -### Local OCR Engine -- **Real Tesseract OCR**: Uses the Rust `tesseract` crate (v0.15) with English language model -- **Global shortcut**: `Alt+O` to trigger region selection and text extraction -- **Clipboard integration**: Extracted text automatically copied to clipboard -- **Preview notification**: Emits `ocr-complete` event with first 100 characters -- **Graceful degradation**: Clear error message if Tesseract is not installed - -### Screenshot Capture -- **Region selection**: `Alt+S` opens a fullscreen transparent overlay; drag to select any screen region -- **Retina-aware**: Correctly maps logical to physical coordinates using monitor scale factors -- **Clipboard copy**: Captured image copied directly to clipboard -- **Save location**: Saved as `gquick_capture.png` on the Desktop - -### File Search (Fast + Smart) -- **Fast search**: Keyword-based filename and path scoring across home directory; returns top 50 results in milliseconds -- **Smart search**: Natural language queries like "find files about budgeting from last week" - - Reads file metadata (created, modified, size) - - Reads text file contents (up to 100KB) for preview - - Time-based filtering: `today`, `yesterday`, `last week`, `last month`, `recent` - - **AI ranking**: Sends file descriptions to your configured AI provider to rank results by actual relevance -- **File index caching**: 5-minute TTL to balance freshness and performance - -### Translation -- **Quick Translate**: Type `t: text` or `tr: text` for instant AI translation with auto language detection (German/English) -- **Full Translation UI**: Type `translate:` or `/translate` to open a dedicated panel with 12 languages, swap button, and copy-to-clipboard - -### Docker Management -- List containers with start/stop/restart actions -- List images with delete action -- Inline preview buttons for quick container control - -### Calculator -- Evaluate mathematical expressions directly in the search bar -- Result copied to clipboard on Enter - -### Web Search -- Quick Google search opened in your default browser - -### App Launcher -- Cross-platform application discovery and launching -- macOS: `.app` bundles from `/Applications` and `/System/Applications` -- Windows: `.lnk` shortcuts from Start Menu -- Linux: `.desktop` files with Name/Exec parsing, NoDisplay/Hidden filtering - -### Settings & Configuration -- **API provider selection**: OpenAI, Google Gemini, Kimi/Moonshot, Anthropic Claude -- **API key input**: Password field with show/hide toggle -- **Live model fetching**: Automatically fetches available models when API key is entered, with 24-hour localStorage caching -- **Configurable global shortcuts**: Open window, screenshot, and OCR shortcuts all customizable via interactive ShortcutRecorder component -- **Platform-aware defaults**: `Alt+Space` on macOS/Linux, `Alt+Shift+Space` on Windows - -### UI/UX Improvements -- **Actions overlay**: `Cmd/Ctrl+K` opens a searchable directory of all plugins and app actions -- **Tooltip component**: Hover tooltips for chat clear button and model indicator -- **ShortcutRecorder component**: Click-to-record global shortcut capture with modifier key validation -- **Smart search badge**: Purple "Smart" badge appears on AI-ranked file search results -- **Loading states**: Spinners for translation, smart search analysis, and model fetching -- **Auto-hide on blur**: Main window hides when focus is lost; dialog-aware (won't hide during native file picker) - ---- - -## Bug Fixes -- Fixed "black screen" captures by correctly mapping logical to physical coordinates using the window's scale factor -- Fixed race conditions when rapidly switching API keys or providers in Settings (AbortController + debounce) -- Fixed selector window not closing reliably by adding a dedicated Rust `close_selector` command -- Fixed file dialog causing main window to hide by tracking dialog open state in Rust -- Fixed multi-monitor capture errors by matching Tauri monitor name with xcap monitor - ---- - -## Migration Notes -No migration required for new users. Install Tesseract OCR for your platform to enable OCR functionality. - ---- - -## Breaking Changes -- **OAuth authentication removed**: Previous OAuth flow was replaced with API Key-only authentication. Users must enter their API key directly in Settings. - ---- - -## Dependencies Added -- `tesseract` Rust crate (0.15) for OCR -- `react-markdown` (10.1.0) and `remark-gfm` (4.0.1) for chat message rendering -- `lucide-react` (1.8.0) for iconography -- `tauri-plugin-dialog` (2.7.0) for native image file picker -- `xcap` (0.9) and `image` (0.25) for screen capture and processing - ---- - -## Known Issues -- API keys are stored in plaintext `localStorage` (not encrypted) -- Chat history is not persisted across app restarts -- File search limited to home directory, max depth 6 -- Tesseract OCR uses English language model only; no UI to select other languages -- SQLite plugin is included but unused diff --git a/package-lock.json b/package-lock.json index 1067e97..ebceb5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gquick", - "version": "0.1.7", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gquick", - "version": "0.1.7", + "version": "0.2.0", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-clipboard-manager": "^2.3.1", diff --git a/package.json b/package.json index 7afd58c..1f5552c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gquick", "private": true, - "version": "0.1.9", + "version": "0.2.0", "type": "module", "engines": { "node": ">=24.15.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8f3470b..3d3b0c3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,26 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +dependencies = [ + "pom", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -465,6 +485,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -520,6 +549,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -628,6 +663,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.60" @@ -666,6 +710,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "cff-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f5b6e9141c036f3ff4ce7b2f7e432b0f00dee416ddcd4f17741d189ddc2e9d" + [[package]] name = "cfg-expr" version = "0.15.8" @@ -712,6 +762,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -1274,6 +1334,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + [[package]] name = "ego-tree" version = "0.10.0" @@ -1306,6 +1375,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1386,6 +1464,15 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "euclid" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -2018,7 +2105,7 @@ dependencies = [ [[package]] name = "gquick" -version = "0.1.9" +version = "0.2.0" dependencies = [ "base64 0.22.1", "chrono", @@ -2027,8 +2114,10 @@ dependencies = [ "image", "jwalk", "libc", + "lopdf", "objc2", "objc2-foundation", + "pdf-extract", "rayon", "reqwest 0.12.28", "rusqlite", @@ -2045,6 +2134,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-updater", "tesseract", + "tokio", "unicode-normalization", "walkdir", "windows-sys 0.59.0", @@ -2533,6 +2623,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -2626,6 +2726,47 @@ dependencies = [ "system-deps 6.2.2", ] +[[package]] +name = "jiff" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys 0.52.0", +] + +[[package]] +name = "jiff-static" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -2989,6 +3130,38 @@ dependencies = [ "imgref", ] +[[package]] +name = "lopdf" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289" +dependencies = [ + "aes", + "bitflags 2.11.1", + "cbc", + "chrono", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.3.4", + "indexmap 2.14.0", + "itoa", + "jiff", + "log", + "md-5", + "nom 8.0.0", + "nom_locate", + "rand 0.9.4", + "rangemap", + "rayon", + "sha2", + "stringprep", + "thiserror 2.0.18", + "time", + "ttf-parser", + "weezl", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3053,6 +3226,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.0" @@ -3229,6 +3412,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -3767,6 +3961,23 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pdf-extract" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28ba1758a3d3f361459645780e09570b573fc3c82637449e9963174c813a98" +dependencies = [ + "adobe-cmap-parser", + "cff-parser", + "encoding_rs", + "euclid", + "log", + "lopdf", + "postscript", + "type1-encoding-parser", + "unicode-normalization", +] + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -4087,6 +4298,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pom" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postscript" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" + [[package]] name = "potential_utf" version = "0.1.5" @@ -4448,6 +4686,12 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + [[package]] name = "rav1e" version = "0.8.1" @@ -5424,6 +5668,17 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -6155,11 +6410,25 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -6418,6 +6687,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type1-encoding-parser" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa10c302f5a53b7ad27fd42a3996e23d096ba39b5b8dd6d9e683a05b01bee749" +dependencies = [ + "pom", +] + [[package]] name = "typeid" version = "1.0.3" @@ -6482,6 +6766,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -6497,6 +6787,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a186df5..f2c936d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gquick" -version = "0.1.9" +version = "0.2.0" description = "A simple, fast and feature rich launcher" authors = ["Ricky Dane Perlick"] edition = "2021" @@ -40,6 +40,9 @@ scraper = "0.22" libc = "0.2" unicode-normalization = "0.1" walkdir = "2" +pdf-extract = "0.10" +lopdf = "0.38" +tokio = { version = "1", features = ["full"] } [target.'cfg(target_os = "macos")'.dependencies] tesseract = "0.15" diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index e5ba724..efc4fe0 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 9a6605d..2cf5382 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 3528e01..32818eb 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index 283e455..7f892e2 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 0d5d00e..0014bb2 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 35bcda1..4d4de29 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 17bc9ee..7e96c50 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 62e7a16..c500035 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index e0799f4..d9ab97f 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 5ef8d1d..a020e85 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index b4de859..93000cb 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index c97b831..03f031a 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index 62d4495..6ca01a4 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 5fe6048..b65a0be 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index 208057c..5305cc4 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index d2b029b..af20f95 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png index fc84c30..74eee46 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index b0ca0d8..b4938f1 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png index 156c22c..28b9200 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png index d856095..6242780 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index 7d96948..e384158 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index fefec32..b6beb9e 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png index 21238d6..21fdcef 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index 15afd70..df4cb80 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index 7672ed9..ccb2103 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index 6174fe5..7d4420f 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index fe54675..c95dc0f 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index 1059050..c3ee6d2 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index f497609..6d546ec 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 3780817..e3870c7 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 43e926d..0f5ce6e 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index f1aa774..187b765 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png index 1749455..7f1aba1 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index 16e32d6..949eea3 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png index 16e32d6..949eea3 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png index d7449dd..4ee6c73 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png index 4b6803f..130ea99 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index 4b3e756..3baab6b 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png index 4b3e756..3baab6b 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png index c17f43e..e1a4dc2 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png index 16e32d6..949eea3 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index 69ccf25..143709b 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png index 69ccf25..143709b 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png index fbfc9e1..76fac24 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png index 5d1b5e9..e11bf60 100644 Binary files a/src-tauri/icons/ios/AppIcon-512@2x.png and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png index fbfc9e1..76fac24 100644 Binary files a/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png index 5373138..d33dfad 100644 Binary files a/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png index dd2505c..a865f8a 100644 Binary files a/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png index 74a5b95..7370c87 100644 Binary files a/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index c0d6b15..6657571 100644 Binary files a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4bdfb55..d1868d4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -542,6 +542,74 @@ mod tests { assert!(result.is_none()); } + + #[test] + fn read_ai_file_content_extracts_pdf_text() { + use lopdf::dictionary; + + let base = std::env::current_dir() + .unwrap() + .join("target") + .join(format!("gquick-pdf-extract-test-{}", std::process::id())); + let target = base.join("document.pdf"); + + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + + // Generate a minimal valid PDF using lopdf + let mut doc = lopdf::Document::with_version("1.5"); + let pages_id = doc.new_object_id(); + + let font_id = doc.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "Type1", + "BaseFont" => "Courier", + }); + + let resources_id = doc.add_object(dictionary! { + "Font" => dictionary! { "F1" => font_id }, + }); + + let content = lopdf::content::Content { + operations: vec![ + lopdf::content::Operation::new("BT", vec![]), + lopdf::content::Operation::new("Tf", vec!["F1".into(), 12.into()]), + lopdf::content::Operation::new("Td", vec![100.into(), 500.into()]), + lopdf::content::Operation::new("Tj", vec![lopdf::Object::string_literal("Hello PDF!")]), + lopdf::content::Operation::new("ET", vec![]), + ], + }; + let content_id = doc.add_object(lopdf::Stream::new(dictionary! {}, content.encode().unwrap())); + + let page_id = doc.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "Contents" => content_id, + "Resources" => resources_id, + "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()], + }); + + doc.objects.insert(pages_id, lopdf::Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => vec![page_id.into()], + "Count" => 1, + })); + + let catalog_id = doc.add_object(dictionary! { + "Type" => "Catalog", + "Pages" => pages_id, + }); + + doc.trailer.set("Root", catalog_id); + doc.save(&target).unwrap(); + + let result = read_ai_file_content(&target, 100); + let _ = std::fs::remove_dir_all(&base); + + assert!(result.is_some()); + let text = result.unwrap(); + assert!(text.contains("Hello PDF!")); + } } #[tauri::command] @@ -1458,12 +1526,30 @@ fn read_opened_text_content(mut file: std::fs::File, max_size: usize) -> Option< } fn read_ai_file_content(path: &std::path::Path, max_size: usize) -> Option { - if !is_text_file(path) { - return None; - } + let extension = path.extension().and_then(|ext| ext.to_str()).map(|ext| ext.to_lowercase()); + if extension.as_deref() == Some("pdf") { + let (mut file, metadata) = open_ai_readable_file(path).ok()?; + if metadata.len() > 10_000_000 { + return Some("Error: PDF file is too large to extract text (limit is 10MB)".to_string()); + } - let (file, _metadata) = open_ai_readable_file(path).ok()?; - read_opened_text_content(file, max_size) + let mut buffer = Vec::with_capacity(metadata.len() as usize); + use std::io::Read; + file.read_to_end(&mut buffer).ok()?; + + let mut content = pdf_extract::extract_text_from_mem(&buffer).ok()?; + if truncate_string_to_byte_boundary(&mut content, max_size) { + content.push_str("\n... [file truncated, content too large] ..."); + } + Some(content) + } else { + if !is_text_file(path) { + return None; + } + + let (file, _metadata) = open_ai_readable_file(path).ok()?; + read_opened_text_content(file, max_size) + } } fn extract_meaningful_keywords(query: &str) -> Vec { @@ -3616,6 +3702,261 @@ fn delete_image_blocking( docker_output(&args) } +#[derive(serde::Serialize)] +struct ExecuteResult { + success: bool, + stdout: String, + stderr: String, +} + +#[tauri::command] +async fn execute_python(code: String) -> Result { + run_blocking(move || execute_python_blocking(code)).await? +} + +fn execute_python_blocking(code: String) -> Result { + use std::io::Write; + use std::process::{Command, Stdio}; + + let timeout = Duration::from_secs(15); + let docker_status = docker_status_blocking(); + + let mut cmd = if docker_status.cli_installed && docker_status.daemon_running { + let mut c = Command::new("docker"); + c.args(["run", "--rm", "-i", "python:alpine", "python"]); + c + } else { + let python3_exists = Command::new("python3").arg("--version").output().is_ok(); + if python3_exists { + Command::new("python3") + } else { + Command::new("python") + } + }; + + #[cfg(unix)] + unsafe { + use std::os::unix::process::CommandExt; + cmd.pre_exec(|| { + if libc::setsid() == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } + + let mut child = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to spawn Python process: {}", e))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(code.as_bytes()) + .map_err(|e| format!("Failed to write Python code to stdin: {}", e))?; + } + + let mut stdout_handle = child.stdout.take().map(read_pipe); + let mut stderr_handle = child.stderr.take().map(read_pipe); + let started = Instant::now(); + + let status = loop { + match child.try_wait() { + Ok(Some(status)) => { + break status; + } + Ok(None) if started.elapsed() <= timeout => { + std::thread::sleep(Duration::from_millis(50)); + } + _ => { + terminate_process_tree(&mut child); + let _ = child.wait(); + if let Some(handle) = stdout_handle.take() { + let _ = handle.join(); + } + if let Some(handle) = stderr_handle.take() { + let _ = handle.join(); + } + return Ok(ExecuteResult { + success: false, + stdout: String::new(), + stderr: "Execution timed out (limit: 15 seconds)".to_string(), + }); + } + } + }; + + let stdout = match stdout_handle { + Some(handle) => handle + .join() + .map_err(|_| "Failed to read stdout thread".to_string())? + .map_err(|e| format!("Stdout read error: {}", e))?, + None => String::new(), + }; + + let stderr = match stderr_handle { + Some(handle) => handle + .join() + .map_err(|_| "Failed to read stderr thread".to_string())? + .map_err(|e| format!("Stderr read error: {}", e))?, + None => String::new(), + }; + + Ok(ExecuteResult { + success: status.success(), + stdout, + stderr, + }) +} + +#[tauri::command] +async fn execute_sql(app: tauri::AppHandle, query: String) -> Result { + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {}", e))?; + let db_path = app_data_dir.join("sandbox.db"); + run_blocking(move || execute_sql_blocking(db_path, query)).await? +} + +fn split_sql_statements(sql: &str) -> Vec { + let mut statements = Vec::new(); + let mut current = String::new(); + let mut in_single_quote = false; + let mut in_double_quote = false; + + let chars: Vec = sql.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c == '\'' && !in_double_quote { + in_single_quote = !in_single_quote; + current.push(c); + } else if c == '"' && !in_single_quote { + in_double_quote = !in_double_quote; + current.push(c); + } else if c == ';' && !in_single_quote && !in_double_quote { + statements.push(current.trim().to_string()); + current.clear(); + } else { + current.push(c); + } + i += 1; + } + let last = current.trim(); + if !last.is_empty() { + statements.push(last.to_string()); + } + statements +} + +fn execute_sql_blocking(db_path: std::path::PathBuf, query: String) -> Result { + let conn = rusqlite::Connection::open(&db_path) + .map_err(|e| format!("Failed to open sandbox database: {}", e))?; + + let statements = split_sql_statements(&query); + let mut stdout = String::new(); + let mut stderr = String::new(); + let mut success = true; + + for stmt in statements { + let trimmed = stmt.trim(); + if trimmed.is_empty() { + continue; + } + + let is_query = trimmed.to_uppercase().starts_with("SELECT") + || trimmed.to_uppercase().starts_with("PRAGMA") + || trimmed.to_uppercase().starts_with("WITH") + || trimmed.to_uppercase().starts_with("EXPLAIN"); + + if is_query { + match conn.prepare(trimmed) { + Ok(mut prepared) => { + let col_count = prepared.column_count(); + let col_names: Vec = prepared.column_names().into_iter().map(|s| s.to_string()).collect(); + + let mut rows_out = Vec::new(); + match prepared.query_map([], |row| { + let mut row_obj = serde_json::Map::new(); + for i in 0..col_count { + let val: serde_json::Value = match row.get_ref(i) { + Ok(rusqlite::types::ValueRef::Null) => serde_json::Value::Null, + Ok(rusqlite::types::ValueRef::Integer(val_int)) => serde_json::Value::Number(val_int.into()), + Ok(rusqlite::types::ValueRef::Real(f)) => { + if let Some(n) = serde_json::Number::from_f64(f) { + serde_json::Value::Number(n) + } else { + serde_json::Value::Null + } + } + Ok(rusqlite::types::ValueRef::Text(t)) => { + serde_json::Value::String(String::from_utf8_lossy(t).into_owned()) + } + Ok(rusqlite::types::ValueRef::Blob(b)) => { + serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(b)) + } + Err(_) => serde_json::Value::Null, + }; + let col_name = col_names.get(i).cloned().unwrap_or_else(|| format!("col_{}", i)); + row_obj.insert(col_name, val); + } + Ok(serde_json::Value::Object(row_obj)) + }) { + Ok(rows_iter) => { + for r in rows_iter { + match r { + Ok(row_val) => rows_out.push(row_val), + Err(e) => { + success = false; + stderr.push_str(&format!("Error fetching row: {}\n", e)); + } + } + } + if success { + if let Ok(json_str) = serde_json::to_string_pretty(&rows_out) { + stdout.push_str(&json_str); + stdout.push('\n'); + } + } + } + Err(e) => { + success = false; + stderr.push_str(&format!("Query error: {}\n", e)); + } + } + } + Err(e) => { + success = false; + stderr.push_str(&format!("Preparation error: {}\n", e)); + } + } + } else { + match conn.execute(trimmed, []) { + Ok(changes) => { + stdout.push_str(&format!("Statement executed successfully. Rows affected: {}\n", changes)); + } + Err(e) => { + success = false; + stderr.push_str(&format!("Execution error for statement [{}]: {}\n", trimmed, e)); + } + } + } + + if !success { + break; + } + } + + Ok(ExecuteResult { + success, + stdout, + stderr, + }) +} + #[tauri::command] async fn manage_container( id: String, @@ -6395,6 +6736,8 @@ pub fn run() { cancel_terminal_command, cancel_all_terminal_commands, hide_main_window, + execute_python, + execute_sql, brew_status, brew_list, brew_search, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3b66673..b2a6c6f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "GQuick", - "version": "0.1.9", + "version": "0.2.0", "identifier": "com.gquick.dev", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 59c0d07..cf407e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from "react"; -import { Search, Command, Settings as SettingsIcon, MessageSquare, ChevronRight, ChevronDown, Send, User, Bot, Loader2, Zap, ImagePlus, X, RotateCcw, StickyNote, Box, Terminal, Plus, RefreshCw, Beer } from "lucide-react"; +import { Search, Command, Settings as SettingsIcon, MessageSquare, ChevronRight, ChevronDown, Send, User, Bot, Loader2, Zap, ImagePlus, X, RotateCcw, StickyNote, Box, Terminal, Plus, RefreshCw, Beer, Copy, Check } from "lucide-react"; import { cn } from "./utils/cn"; import { getPluginsForQuery, plugins } from "./plugins"; import { SearchResultItem } from "./plugins/types"; @@ -8,6 +8,8 @@ import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window"; import { listen, UnlistenFn } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; import { MarkdownMessage } from "./components/MarkdownMessage"; +import { ToolCallsView } from "./components/ToolCallsView"; +import { GroundingMetadataView } from "./components/GroundingMetadataView"; import { Tooltip } from "./components/Tooltip"; import { NotesView } from "./components/NotesView"; import { DockerView, type DockerInitialImage } from "./components/DockerView"; @@ -21,6 +23,9 @@ import { getSavedLocation } from "./utils/location"; import { recordUsage, getRecentItems } from "./utils/usageTracker"; import { isSpeedtestRunning, cancelSpeedtest } from "./plugins/speedtest"; import UpdateModal from "./components/UpdateModal"; +import geminiLogo from "./assets/gemini_logo.svg"; +import gptLogo from "./assets/gpt-logo.webp"; + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; const modKey = isMac ? '⌘' : 'Ctrl'; @@ -117,13 +122,16 @@ interface ChatImage { base64: string; } -interface Message { +export interface Message { id: string; role: "user" | "assistant" | "tool"; content: string; images?: ChatImage[]; toolCalls?: ToolCall[]; // for assistant messages that initiated tool calls toolCallId?: string; // for tool result messages + groundingMetadata?: any; + provider?: string; + model?: string; } interface InlineTerminalResult { @@ -240,6 +248,18 @@ function isLikelyInteractiveCommand(command: string): boolean { return REPL_COMMANDS.has(executable) && tokens.length === index + 1; } +const getModelLogo = (provider?: string, model?: string) => { + const normProvider = provider?.toLowerCase() || ""; + const normModel = model?.toLowerCase() || ""; + if (normProvider === "google" || normModel.includes("gemini")) { + return geminiLogo; + } + if (normProvider === "openai" || normModel.includes("gpt")) { + return gptLogo; + } + return null; +}; + function App() { const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); @@ -283,13 +303,25 @@ function App() { const chatScrollRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); const [showScrollButton, setShowScrollButton] = useState(false); - const [selectedModel, setSelectedModel] = useState(""); + const [selectedModel, setSelectedModel] = useState(() => localStorage.getItem("selected-model") || ""); + const [apiProvider, setApiProvider] = useState(() => localStorage.getItem("api-provider") || "openai"); + const getMessageLogo = (msg: Message) => { + return getModelLogo(msg.provider || apiProvider, msg.model || selectedModel); + }; const [isLoading, setIsLoading] = useState(false); const [isSearching, setIsSearching] = useState(false); const [searchStatus, setSearchStatus] = useState(null); const [isTranslating, setIsTranslating] = useState(false); const [inlineCommand, setInlineCommand] = useState(null); const [attachedImages, setAttachedImages] = useState([]); + const [copiedMessageId, setCopiedMessageId] = useState(null); + const handleCopyMessage = (id: string, content: string) => { + navigator.clipboard.writeText(content); + setCopiedMessageId(id); + setTimeout(() => { + setCopiedMessageId(null); + }, 2000); + }; const [notesContext, setNotesContext] = useState<{ title: string; content: string }[] | null>(null); const [dockerInitialImage, setDockerInitialImage] = useState(null); const [homebrewInitialPackage, setHomebrewInitialPackage] = useState<{ name: string; isCask: boolean } | null>(null); @@ -345,6 +377,8 @@ function App() { useEffect(() => { const model = localStorage.getItem("selected-model"); if (model) setSelectedModel(model); + const provider = localStorage.getItem("api-provider"); + if (provider) setApiProvider(provider || "openai"); }, [view]); // Sync saved shortcuts with Rust backend on mount @@ -1510,26 +1544,135 @@ function App() { ? `The user's current location is ${savedLocation.name}${savedLocation.country ? `, ${savedLocation.country}` : ""} (lat: ${savedLocation.latitude}, lon: ${savedLocation.longitude}). Use this location by default for weather and location-related queries unless the user specifies a different location.` : ""; - const baseSystemContent = "You are GQuick, a helpful AI assistant. You have access to tools that can help you perform actions like calculations, file searches, note management, network queries, and web search. Use them when helpful. Always format your responses using Markdown for better readability. Use code blocks for code, lists for enumerations, bold/italic for emphasis, and tables when appropriate." + (locationContext ? "\n\n" + locationContext : ""); + const enabledToolNames = new Set(tools.map(t => t.name)); + + const toolsDescriptionMap: Record = { + web_search: "Search the web for current information.", + search_files: "Search local files and folders by filename or keywords.", + read_file: "Access the content of local text files.", + search_notes: "Search your saved notes.", + create_note: "Save new information to your notes database.", + get_current_weather: "Get current weather conditions for a location.", + get_weather_forecast: "Get 7-day weather forecast for a location.", + calculate: "Perform mathematical calculations.", + get_network_info: "Get local network details (IP address, Wi-Fi SSID, latency).", + execute_python: "Write and execute Python code in a sandboxed/local environment.", + execute_sql: "Run SQL queries against a sandboxed SQLite database." + }; + + const availableToolsList = tools + .map(t => `- \`${t.name}\`: ${toolsDescriptionMap[t.name] || t.description}`) + .join("\n"); + + const infoSearchSection = [ + enabledToolNames.has("web_search") && "* `web_search`: Search the web for current information.", + enabledToolNames.has("search_files") && "* `search_files`: Search local files and folders by filename or keywords.", + enabledToolNames.has("search_notes") && "* `search_notes`: Search your saved notes." + ].filter((val): val is string => typeof val === "string").join("\n"); + + const utilitiesSection = [ + enabledToolNames.has("get_current_weather") && "* `get_current_weather`: Get current weather conditions for a location.", + enabledToolNames.has("get_weather_forecast") && "* `get_weather_forecast`: Get 7-day weather forecast for a location.", + enabledToolNames.has("calculate") && "* `calculate`: Perform mathematical calculations.", + enabledToolNames.has("get_network_info") && "* `get_network_info`: Get local network details (IP address, Wi-Fi SSID, latency)." + ].filter((val): val is string => typeof val === "string").join("\n"); + + const dataMgmtSection = [ + enabledToolNames.has("read_file") && "* `read_file`: Access the content of local text files.", + enabledToolNames.has("create_note") && "* `create_note`: Save new information to your notes database." + ].filter((val): val is string => typeof val === "string").join("\n"); + + const codeExecSection = [ + enabledToolNames.has("execute_python") && "* `execute_python`: Write and execute Python code in a sandboxed/local environment.", + enabledToolNames.has("execute_sql") && "* `execute_sql`: Run SQL queries against a sandboxed SQLite database." + ].filter((val): val is string => typeof val === "string").join("\n"); + + let exactResponse = "I have access to the following tools:\n\n"; + if (infoSearchSection) { + exactResponse += "**Information & Search**\n" + infoSearchSection + "\n\n"; + } + if (utilitiesSection) { + exactResponse += "**Utilities**\n" + utilitiesSection + "\n\n"; + } + if (dataMgmtSection) { + exactResponse += "**Data Management**\n" + dataMgmtSection + "\n\n"; + } + if (codeExecSection) { + exactResponse += "**Code Execution**\n" + codeExecSection + "\n\n"; + } + + const systemToolsContent = + "### Available Tools:\n" + + "You have access to the following tools. Always refer to them by their exact names and without any prefixes (like `functions.`):\n" + + availableToolsList + "\n\n" + + "If the user asks you what tools you have, what tools are available, or what you can do, you MUST reply with this exact markdown response:\n\n" + + exactResponse; + + const hasPython = enabledToolNames.has("execute_python"); + const hasSql = enabledToolNames.has("execute_sql"); + let executionCapabilities = ""; + if (hasPython && hasSql) { + executionCapabilities = ", and execute Python and SQL code"; + } else if (hasPython) { + executionCapabilities = ", and execute Python code"; + } else if (hasSql) { + executionCapabilities = ", and execute SQL queries"; + } + const introText = `You are GQuick, a highly efficient, helpful, and concise AI assistant. You have access to tools to help perform calculations, search files, manage notes, query network info, and search the web${executionCapabilities}. Use them when needed.\n\n`; + + const baseSystemContent = + introText + + systemToolsContent + + "### Tool Usage Guidelines:\n" + + "- **Be Proactive**: Do not ask the user for clarification or confirmation before using search tools (like `search_files` or `search_notes`). If the user asks to find, search, list, or locate something, immediately call the appropriate search tool using the query inferred from their message.\n" + + "- **Query Inference**: If a search query is general (e.g., 'outgoing invoices' or 'Ausgangsrechnungen'), use it directly as the search query. Do not ask for exact filenames or folder paths.\n" + + "- **Location Context**: Only use the user's location for weather or explicitly location-based queries. Do not reference it for file searches, note searches, calculations, or other unrelated tasks.\n\n" + + "### Response Style & Guidelines:\n" + + "- **Format**: Always use clear **Markdown** (headers, bold text, code blocks, bullet points, tables).\n" + + "- **Conciseness**: Keep answers short, direct, and to the point. Omit fluff, greetings, and generic transitions.\n" + + "- **Visual Markers**: Do NOT use emojis or icons. Instead, use markdown-supported visual markers to emphasize important information:\n" + + " - Use blockquotes (e.g., `> **Warning:** [message]` or `> **Note:** [message]`) to highlight critical alerts, warnings, or tips.\n" + + " - Use bolding (`**text**`) or inline code (``code``) to draw attention to key parameters, files, or actions.\n" + + " - Use bulleted lists and headers to structure key takeaways and summaries." + + (locationContext ? "\n\n" + locationContext : ""); + const systemContent = notesContextStr - ? `You are GQuick, a helpful AI assistant. You have access to tools that can help you perform actions like calculations, file searches, note management, network queries, and web search. Use them when helpful.\n\nThe user has shared their saved notes below. Use the notes to answer their question if relevant, but you can also draw on your general knowledge. If the notes contain the answer, reference them. If not, answer from your knowledge. Always format responses using Markdown.` + (locationContext ? "\n\n" + locationContext : "") + ? `You are GQuick, a highly efficient, helpful, and concise AI assistant. You have access to tools to help perform calculations, search files, manage notes, query network info, and search the web${executionCapabilities}. Use them when needed.\n\n` + + "The user has shared their saved notes below. Use these notes to answer their question if relevant, referencing them when helpful. If not answered by the notes, answer using your general knowledge.\n\n" + + systemToolsContent + + "### Tool Usage Guidelines:\n" + + "- **Be Proactive**: Do not ask the user for clarification or confirmation before using search tools (like `search_files` or `search_notes`). If the user asks to find, search, list, or locate something, immediately call the appropriate search tool using the query inferred from their message.\n" + + "- **Query Inference**: If a search query is general (e.g., 'outgoing invoices' or 'Ausgangsrechnungen'), use it directly as the search query. Do not ask for exact filenames or folder paths.\n" + + "- **Location Context**: Only use the user's location for weather or explicitly location-based queries. Do not reference it for file searches, note searches, calculations, or other unrelated tasks.\n\n" + + "### Response Style & Guidelines:\n" + + "- **Format**: Always use clear **Markdown** (headers, bold text, code blocks, bullet points, tables).\n" + + "- **Conciseness**: Keep answers short, direct, and to the point. Omit fluff, greetings, and generic transitions.\n" + + "- **Visual Markers**: Do NOT use emojis or icons. Instead, use markdown-supported visual markers to emphasize important information:\n" + + " - Use blockquotes (e.g., `> **Warning:** [message]` or `> **Note:** [message]`) to highlight critical alerts, warnings, or tips.\n" + + " - Use bolding (`**text**`) or inline code (``code``) to draw attention to key parameters, files, or actions.\n" + + " - Use bulleted lists and headers to structure key takeaways and summaries." + + (locationContext ? "\n\n" + locationContext : "") : baseSystemContent; async function streamWithTools(msgs: Message[], notesContext: string | null, depth = 0) { if (depth > 5) { const assistantId = (Date.now() + Math.random()).toString(); - setMessages(prev => [...prev, { id: assistantId, role: "assistant", content: "Too many tool call rounds. Stopping to prevent infinite loop." }]); + setMessages(prev => [...prev, { id: assistantId, role: "assistant", content: "Too many tool call rounds. Stopping to prevent infinite loop.", provider, model }]); setIsLoading(false); return; } const assistantId = (Date.now() + Math.random()).toString(); - setMessages(prev => [...prev, { id: assistantId, role: "assistant", content: "" }]); + setMessages(prev => [...prev, { id: assistantId, role: "assistant", content: "", provider, model }]); const updateAssistantContent = (text: string) => { setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: text } : m)); }; + const updateAssistantGrounding = (metadata: any) => { + setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, groundingMetadata: metadata } : m)); + }; + const history = msgs.filter(m => m.role !== "assistant" || m.id !== "1"); const lastUserIndex = history.map(m => m.role).lastIndexOf("user"); const processedHistory = history.map((m, idx) => @@ -1554,6 +1697,7 @@ function App() { const callbacks = { onContent: updateAssistantContent, + onGrounding: updateAssistantGrounding, onDone: async (toolCalls?: ToolCall[]) => { try { if (toolCalls && toolCalls.length > 0) { @@ -1574,7 +1718,7 @@ function App() { })); const afterToolMessages = [...msgs, - { id: assistantId, role: "assistant", content: "Using tools...", toolCalls: toolCalls } as Message, + { id: assistantId, role: "assistant", content: "Using tools...", toolCalls: toolCalls, provider, model } as Message, ...toolResultMessages ]; setMessages(afterToolMessages); @@ -1640,14 +1784,23 @@ function App() { callbacks ); } else if (provider === "google") { + const customTools = tools.filter(t => t.name !== "web_search"); + const toolsArray: any[] = []; + if (customTools.length > 0) { + toolsArray.push(convertToolsForProvider(customTools, "google")); + } + toolsArray.push({ googleSearch: {} }); + const body: any = { systemInstruction: { role: "user", parts: [{ text: systemContent }] }, contents: apiMessages, + tools: toolsArray, + }; + body.toolConfig = { + functionCallingConfig: { mode: "AUTO" }, + includeServerSideToolInvocations: true, + include_server_side_tool_invocations: true }; - if (providerTools) { - body.tools = [providerTools]; - body.toolConfig = { functionCallingConfig: { mode: "AUTO" } }; - } await streamGeminiTools( `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`, { "Content-Type": "application/json" }, @@ -1731,7 +1884,13 @@ function App() { {view === "chat" && selectedModel && (
- + {(() => { + const logo = getModelLogo(apiProvider, selectedModel); + if (logo) { + return ; + } + return ; + })()}
)} @@ -1962,8 +2121,8 @@ function App() { ) : view === "homebrew" ? ( ) : view === "chat" ? ( -
-
+
+
{notesContext && (
@@ -1979,25 +2138,52 @@ function App() {
)} - {messages.filter(msg => msg.role !== "tool").map(msg => ( -
-
- {msg.role === "assistant" ? "G" : } -
+ {messages.filter(msg => msg.role !== "tool").map(msg => { + const logoUrl = msg.role === "assistant" ? getMessageLogo(msg) : null; + return ( +
+
+ {msg.role === "assistant" ? ( + logoUrl ? ( + + ) : ( + "G" + ) + ) : ( + + )} +
0 + ? "bg-white/5 border-white/5 p-1.5" + : "bg-white/5 text-zinc-200 border-white/5 px-4 pr-10 py-2" + : "bg-blue-600/10 text-blue-100 border-blue-500/20 px-4 pr-10 py-2" )}> {msg.role === "assistant" ? ( - msg.content ? ( - + msg.toolCalls && msg.toolCalls.length > 0 ? ( + ) : ( -
- - Thinking... +
+ {msg.content ? ( + + ) : ( +
+ + Thinking... +
+ )} + {msg.groundingMetadata && ( + + )}
) ) : ( @@ -2019,20 +2205,34 @@ function App() { ))}
)} -
+ {msg.content && !(msg.toolCalls && msg.toolCalls.length > 0) && ( + + )}
- ))} +
+ ); + })}
- {showScrollButton && ( - - )}
+ {showScrollButton && ( + + )}
) : query ? (
diff --git a/src/Settings.tsx b/src/Settings.tsx index acc9a26..d1024c5 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useRef, useCallback } from "react"; -import { Key, Eye, EyeOff, Loader2, Command, Save, Power, AlertTriangle, MapPin, X, Download, RefreshCw } from "lucide-react"; +import { Key, Eye, EyeOff, Loader2, Command, Save, Power, AlertTriangle, MapPin, X, Download, RefreshCw, Zap } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { getVersion } from "@tauri-apps/api/app"; import ShortcutRecorder from "./components/ShortcutRecorder"; import { getSavedLocation, saveLocation, clearSavedLocation, SavedLocation, searchLocations } from "./utils/location"; import UpdateModal from "./components/UpdateModal"; +import { plugins } from "./plugins"; interface Model { id: string; @@ -48,6 +49,7 @@ export default function Settings({ onClose }: { onClose: () => void }) { const quitButtonRef = useRef(null); const cancelQuitRef = useRef(null); const confirmQuitRef = useRef(null); + const [enabledTools, setEnabledTools] = useState>({}); // Location state const [savedLocation, setSavedLocation] = useState(null); @@ -115,6 +117,23 @@ export default function Settings({ onClose }: { onClose: () => void }) { // Load saved location setSavedLocation(getSavedLocation()); + + // Load enabled tools state + const toolsState: Record = {}; + for (const plugin of plugins) { + if (plugin.tools) { + for (const tool of plugin.tools) { + const saved = localStorage.getItem(`tool-enabled-${tool.name}`); + if (saved === null) { + const isDefaultDisabled = tool.name === "execute_python" || tool.name === "execute_sql"; + toolsState[tool.name] = !isDefaultDisabled; + } else { + toolsState[tool.name] = saved === "true"; + } + } + } + } + setEnabledTools(toolsState); }, []); // Fetch models when provider or api key changes @@ -270,10 +289,23 @@ export default function Settings({ onClose }: { onClose: () => void }) { setLocationQuery(""); }; + const handleToggleTool = (toolName: string) => { + setEnabledTools((prev) => ({ + ...prev, + [toolName]: !prev[toolName], + })); + }; + const saveSettings = () => { localStorage.setItem("api-key", apiKey); localStorage.setItem("api-provider", apiProvider); if (selectedModel) localStorage.setItem("selected-model", selectedModel); + + // Save tools state + Object.entries(enabledTools).forEach(([toolName, isEnabled]) => { + localStorage.setItem(`tool-enabled-${toolName}`, isEnabled ? "true" : "false"); + }); + onClose(); }; @@ -576,6 +608,70 @@ export default function Settings({ onClose }: { onClose: () => void }) {
+ {/* Tools Configuration */} +
+
+ +
+ +
+

Enable or disable tools the AI assistant can use. Disabled tools will not be accessible to the AI models during chat.

+
+ +
+ {plugins + .filter((p) => p.tools && p.tools.length > 0) + .map((plugin) => ( +
+
+ + {plugin.metadata.title} +
+
+ {plugin.tools?.map((tool) => { + const isEnabled = + enabledTools[tool.name] ?? + (tool.name !== "execute_python" && tool.name !== "execute_sql"); + return ( +
+
+ + {tool.name} + +

+ {tool.description} +

+ {tool.name === "execute_python" && ( +

+ Note: Uses a Docker container as a sandbox if Docker is installed, otherwise falls back to your local Python installation. +

+ )} +
+ +
+ ); + })} +
+
+ ))} +
+
+ {/* Location Configuration */}
diff --git a/src/assets/gemini_logo.svg b/src/assets/gemini_logo.svg new file mode 100644 index 0000000..ecc24b6 --- /dev/null +++ b/src/assets/gemini_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/gpt-logo.webp b/src/assets/gpt-logo.webp new file mode 100644 index 0000000..2ab1ac7 Binary files /dev/null and b/src/assets/gpt-logo.webp differ diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/GroundingMetadataView.tsx b/src/components/GroundingMetadataView.tsx new file mode 100644 index 0000000..565bdbf --- /dev/null +++ b/src/components/GroundingMetadataView.tsx @@ -0,0 +1,77 @@ +import { Globe, ExternalLink, Search } from "lucide-react"; + +interface GroundingMetadataViewProps { + metadata: { + webSearchQueries?: string[]; + groundingChunks?: Array<{ + web?: { + uri: string; + title: string; + }; + }>; + }; +} + +export function GroundingMetadataView({ metadata }: GroundingMetadataViewProps) { + const queries = metadata.webSearchQueries || []; + const chunks = metadata.groundingChunks || []; + + if (queries.length === 0 && chunks.length === 0) return null; + + const getDomain = (urlStr: string) => { + try { + const url = new URL(urlStr); + return url.hostname.replace("www.", ""); + } catch { + return "link"; + } + }; + + return ( +
+ {/* Search Queries */} + {queries.length > 0 && ( +
+ + Searched Google: + {queries.map((q, idx) => ( + + "{q}"{idx < queries.length - 1 ? ", " : ""} + + ))} +
+ )} + + {/* Grounding Sources */} + {chunks.length > 0 && ( +
+
Sources
+
+ {chunks.map((chunk, idx) => { + const web = chunk.web; + if (!web || !web.uri) return null; + + const title = web.title || getDomain(web.uri); + const domain = getDomain(web.uri); + + return ( + + + {domain} + + + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/components/ToolCallsView.tsx b/src/components/ToolCallsView.tsx new file mode 100644 index 0000000..cbc5c0f --- /dev/null +++ b/src/components/ToolCallsView.tsx @@ -0,0 +1,280 @@ +import { useState } from "react"; +import { Message } from "../App"; +import { cn } from "../utils/cn"; +import { + Globe, + Search, + Terminal, + Zap, + StickyNote, + Cloud, + Network, + ChevronRight, + ChevronDown, + Loader2, + Check, + FileCode, + AlertCircle, + X +} from "lucide-react"; + +interface ToolCallsViewProps { + msg: Message; + messages: Message[]; +} + +function getToolDetails(name: string, args: Record, isCompleted: boolean, isFailed: boolean) { + let Icon = Terminal; + let label = `Tool: ${name}`; + let description = isCompleted + ? `Executed ${name}` + : `Executing ${name}...`; + + switch (name) { + case "web_search": + Icon = Globe; + label = "Web Search"; + description = isCompleted + ? `Searched web for "${args.query || ""}"` + : `Searching web for "${args.query || ""}"...`; + break; + case "search_files": + Icon = Search; + label = "File Search"; + description = isCompleted + ? `Searched files for "${args.query || ""}"` + : `Searching files for "${args.query || ""}"...`; + break; + case "read_file": + Icon = FileCode; + label = "Read File"; + const fileBasename = args.path ? args.path.split("/").pop() : ""; + description = isCompleted + ? `Read file "${fileBasename || args.path}"` + : `Reading file "${fileBasename || args.path}"...`; + break; + case "calculate": + Icon = Zap; + label = "Calculator"; + description = isCompleted + ? `Calculated "${args.expression || ""}"` + : `Calculating "${args.expression || ""}"...`; + break; + case "search_notes": + Icon = StickyNote; + label = "Search Notes"; + description = isCompleted + ? `Searched notes for "${args.query || ""}"` + : `Searching notes for "${args.query || ""}"...`; + break; + case "create_note": + Icon = StickyNote; + label = "Create Note"; + description = isCompleted + ? `Created note "${args.title || ""}"` + : `Creating note "${args.title || ""}"...`; + break; + case "get_current_weather": + Icon = Cloud; + label = "Current Weather"; + description = isCompleted + ? `Fetched current weather for "${args.location || ""}"` + : `Fetching current weather for "${args.location || ""}"...`; + break; + case "get_weather_forecast": + Icon = Cloud; + label = "Weather Forecast"; + description = isCompleted + ? `Fetched weather forecast for "${args.location || ""}"` + : `Fetching weather forecast for "${args.location || ""}"...`; + break; + case "get_network_info": + Icon = Network; + label = "Network Info"; + description = isCompleted + ? "Retrieved network info" + : "Retrieving network info..."; + break; + case "execute_python": + Icon = Terminal; + label = "Python Sandbox"; + description = isCompleted + ? "Run Python code" + : "Running Python code..."; + break; + case "execute_sql": + Icon = Terminal; + label = "SQL Sandbox"; + description = isCompleted + ? "Run SQL query" + : "Running SQL query..."; + break; + } + + if (isFailed && isCompleted) { + description = `Failed: ${description + .replace(/^Executed\s+/i, "executing ") + .replace(/^Searched\s+/i, "searching ") + .replace(/^Read\s+/i, "reading ") + .replace(/^Calculated\s+/i, "calculating ") + .replace(/^Fetched\s+/i, "fetching ") + .replace(/^Retrieved\s+/i, "retrieving ") + .replace(/^Run\s+/i, "running ")}`; + } + + return { Icon, label, description }; +} + +export function ToolCallsView({ msg, messages }: ToolCallsViewProps) { + const [isOpen, setIsOpen] = useState(false); + const [expandedToolCalls, setExpandedToolCalls] = useState>({}); + + const toolCalls = msg.toolCalls || []; + if (toolCalls.length === 0) return null; + + // Determine if any tool call is still running + const completedCount = toolCalls.filter(tc => + messages.some(m => m.role === "tool" && m.toolCallId === tc.id) + ).length; + + const isAllCompleted = completedCount === toolCalls.length; + + // Determine if any tool call failed + const hasFailed = toolCalls.some(tc => { + const resultMsg = messages.find(m => m.role === "tool" && m.toolCallId === tc.id); + return resultMsg && (resultMsg.content.startsWith("Tool failed:") || resultMsg.content.includes("failed:")); + }); + + const runningToolCall = toolCalls.find(tc => + !messages.some(m => m.role === "tool" && m.toolCallId === tc.id) + ); + + const runningDetails = runningToolCall + ? getToolDetails(runningToolCall.name, runningToolCall.arguments, false, false) + : null; + + const toolNames = Array.from( + new Set( + toolCalls.map(tc => { + const { label } = getToolDetails(tc.name, tc.arguments, false, false); + return label; + }) + ) + ).join(", "); + + const toggleOpen = () => setIsOpen(!isOpen); + + const toggleExpandTool = (id: string) => { + setExpandedToolCalls(prev => ({ + ...prev, + [id]: !prev[id] + })); + }; + + return ( +
+ {/* Header */} + + + {/* Expandable Tools List */} + {isOpen && ( +
+ {toolCalls.map((tc) => { + const resultMsg = messages.find(m => m.role === "tool" && m.toolCallId === tc.id); + const isCompleted = !!resultMsg; + const isFailed = isCompleted && (resultMsg.content.startsWith("Tool failed:") || resultMsg.content.includes("failed:")); + const isExpanded = !!expandedToolCalls[tc.id]; + + const { Icon, label, description } = getToolDetails(tc.name, tc.arguments, isCompleted, isFailed); + + return ( +
+ {/* Tool Row Header */} + + + {/* Details Section */} + {isExpanded && ( +
+ {/* Arguments */} +
+
Arguments
+
+                        {JSON.stringify(tc.arguments, null, 2)}
+                      
+
+ + {/* Result */} + {isCompleted && resultMsg && ( +
+
Response
+
+                          {resultMsg.content}
+                        
+
+ )} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/plugins/codeExecution.tsx b/src/plugins/codeExecution.tsx new file mode 100644 index 0000000..7638b5a --- /dev/null +++ b/src/plugins/codeExecution.tsx @@ -0,0 +1,374 @@ +import React, { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Code2, Play, Copy, Check, Loader2, Terminal, Database } from "lucide-react"; +import { GQuickPlugin, SearchResultItem, ToolResult } from "./types"; +import { cn } from "../utils/cn"; + +interface ExecuteResult { + success: boolean; + stdout: string; + stderr: string; +} + +interface CodeExecutionViewProps { + initialType: "python" | "sql"; + initialCode: string; +} + +function CodeExecutionView({ initialType, initialCode }: CodeExecutionViewProps) { + const [type, setType] = useState<"python" | "sql">(initialType); + const [code, setCode] = useState(initialCode); + const [isLoading, setIsLoading] = useState(false); + const [output, setOutput] = useState(""); + const [success, setSuccess] = useState(null); + const [error, setError] = useState(""); + const [copied, setCopied] = useState(false); + const [hasEdited, setHasEdited] = useState(false); + + // Sync type and code if the user updates them from the main search bar, + // but only if the user hasn't started manually editing the code yet. + useEffect(() => { + if (!hasEdited) { + setCode(initialCode); + } + }, [initialCode, hasEdited]); + + useEffect(() => { + setType(initialType); + }, [initialType]); + + const handleRun = async () => { + if (!code.trim()) return; + + setIsLoading(true); + setError(""); + setOutput(""); + setSuccess(null); + + try { + if (type === "python") { + const result = await invoke("execute_python", { code }); + setSuccess(result.success); + if (result.success) { + setOutput(result.stdout || "Execution succeeded with no output."); + } else { + setError(result.stderr || "Execution failed with no error output."); + } + } else { + const result = await invoke("execute_sql", { query: code }); + setSuccess(result.success); + if (result.success) { + setOutput(result.stdout || "Query succeeded with no output."); + } else { + setError(result.stderr || "Query failed with no error output."); + } + } + } catch (err: any) { + setSuccess(false); + setError(err.toString()); + } finally { + setIsLoading(false); + } + }; + + const handleCopy = async () => { + const textToCopy = output || error; + if (!textToCopy) return; + try { + await navigator.clipboard.writeText(textToCopy); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + console.error("Failed to copy", e); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + void handleRun(); + } + }; + + return ( +
+
+
+ + +
+ + Press ⌘↵ or ⌃↵ to run + +
+ +
+