Compare commits
10 Commits
223a09c636
...
c03e887b71
| Author | SHA1 | Date | |
|---|---|---|---|
| c03e887b71 | |||
| ff212e4d0b | |||
| da0001e77e | |||
| c73339bb4c | |||
| 8c13b4ec97 | |||
| 0cba457fb7 | |||
| a485cf7ee3 | |||
| 95c9470411 | |||
| 93e526af72 | |||
| 5c5d256cee |
14
CLAUDE.md
@@ -9,13 +9,15 @@ Tusk is a PostgreSQL database management GUI built with Tauri 2 (Rust backend) a
|
|||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run tauri dev # Start full app in dev mode (Vite HMR + Rust backend)
|
npm run app:dev # Start full app in dev mode (Vite HMR + Rust backend) — alias for `tauri dev`
|
||||||
|
npm run app:bundle # Build production desktop executable — alias for `tauri build`
|
||||||
npm run dev # Start only the Vite frontend dev server (port 5173)
|
npm run dev # Start only the Vite frontend dev server (port 5173)
|
||||||
npm run build # Build frontend (tsc + vite build)
|
npm run build # Build frontend (tsc + vite build)
|
||||||
npm run tauri build # Build production desktop executable
|
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`npm run tauri <cmd>` still works for any raw Tauri CLI subcommand.
|
||||||
|
|
||||||
Rust backend builds automatically via Tauri when running `npm run tauri dev/build`. To check Rust compilation independently:
|
Rust backend builds automatically via Tauri when running `npm run tauri dev/build`. To check Rust compilation independently:
|
||||||
```bash
|
```bash
|
||||||
cd src-tauri && cargo check
|
cd src-tauri && cargo check
|
||||||
@@ -39,10 +41,10 @@ All frontend-backend communication goes through Tauri's typed IPC. The full pipe
|
|||||||
|
|
||||||
### Backend (src-tauri/src/)
|
### Backend (src-tauri/src/)
|
||||||
|
|
||||||
- **`state.rs`** — `AppState`: holds `RwLock<HashMap<connection_id, PgPool>>`, read-only flags, config path. Connections default to read-only.
|
- **`state.rs`** — `AppState`: holds connection pools (`pools`, `ch_clients`), read-only flags, DB flavors (PostgreSQL/Greenplum/ClickHouse), config path, chat-agent caches (`overview_cache`, `tables_by_db_cache`), MCP signal channels, and `ai_settings`. Connections default to read-only.
|
||||||
- **`error.rs`** — `TuskError` enum with `thiserror`, serialized as string for IPC. `TuskResult<T>` type alias.
|
- **`error.rs`** — `TuskError` enum with `thiserror`, serialized as string for IPC. `TuskResult<T>` type alias.
|
||||||
- **`utils.rs`** — `escape_ident()` for safe SQL identifier quoting.
|
- **`utils.rs`** — `escape_ident()` for safe SQL identifier quoting.
|
||||||
- **`commands/`** — one module per domain (connections, queries, schema, data, export, management, history, saved_queries). Each function takes `State<'_, AppState>` and returns `TuskResult<T>`.
|
- **`commands/`** — one module per domain (connections, queries, schema, data, export, management, history, saved_queries, ai, chat, chat_tools, memory, settings). Each function takes `State<'_, AppState>` and returns `TuskResult<T>`.
|
||||||
- **`models/`** — serde-serializable structs matching TypeScript types.
|
- **`models/`** — serde-serializable structs matching TypeScript types.
|
||||||
|
|
||||||
SQL safety: identifiers use `escape_ident()`, data queries use sqlx parameterized queries. Read-only mode wraps queries in `SET TRANSACTION READ ONLY` + `ROLLBACK`.
|
SQL safety: identifiers use `escape_ident()`, data queries use sqlx parameterized queries. Read-only mode wraps queries in `SET TRANSACTION READ ONLY` + `ROLLBACK`.
|
||||||
@@ -51,7 +53,8 @@ SQL safety: identifiers use `escape_ident()`, data queries use sqlx parameterize
|
|||||||
|
|
||||||
- **State**: Zustand store (`stores/app-store.ts`) — connections, active connection/database, tabs, read-only flags, pg version.
|
- **State**: Zustand store (`stores/app-store.ts`) — connections, active connection/database, tabs, read-only flags, pg version.
|
||||||
- **Data fetching**: TanStack React Query hooks in `hooks/` — one hook per domain, wrapping `src/lib/tauri.ts` functions.
|
- **Data fetching**: TanStack React Query hooks in `hooks/` — one hook per domain, wrapping `src/lib/tauri.ts` functions.
|
||||||
- **UI**: shadcn/ui + Radix primitives, Tailwind CSS 4, dark mode via next-themes. SQL editor uses CodeMirror.
|
- **AI chat**: tool-calling architecture — backend commands in `commands/chat.rs` / `commands/chat_tools.rs`, frontend tool definitions in `components/chat/tool-registry.ts`. Chat panel (`ChatPanel.tsx`) replaces the former inline `AiBar`, explain/fix, and chart-preview UIs.
|
||||||
|
- **UI**: shadcn/ui + Radix primitives, Tailwind CSS 4, warm dark "Graphite & Honey" theme (IBM Plex Mono, honey accent). CodeMirror SQL editor with a custom theme from `src/lib/editor-theme.ts`.
|
||||||
- **Layout**: resizable panels (sidebar + main area with tab bar).
|
- **Layout**: resizable panels (sidebar + main area with tab bar).
|
||||||
|
|
||||||
### Stored Data
|
### Stored Data
|
||||||
@@ -64,3 +67,4 @@ Connections are persisted to `~/.config/tusk/connections.json`. History and save
|
|||||||
- **Path alias**: `@` maps to `./src`
|
- **Path alias**: `@` maps to `./src`
|
||||||
- **Rust errors**: always use `TuskError` variants, never `unwrap()` in commands
|
- **Rust errors**: always use `TuskError` variants, never `unwrap()` in commands
|
||||||
- **New PG types**: add conversion case in `pg_value_to_json()` in `commands/queries.rs`
|
- **New PG types**: add conversion case in `pg_value_to_json()` in `commands/queries.rs`
|
||||||
|
- **AI chat tools**: add Rust-side tool implementation in `commands/chat_tools.rs` and register it in both the backend dispatch (`ChatTool`) and the frontend `components/chat/tool-registry.ts` (schema, display component, card builder).
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
<title>Tusk</title>
|
<title>Tusk</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400;1,500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"app:dev": "tauri dev",
|
||||||
|
"app:bundle": "tauri build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-sql": "^6.10.0",
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 83 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
BIN
src-tauri/icons/icon-source.png
Normal file
|
After Width: | Height: | Size: 301 KiB |
39
src-tauri/icons/icon-source.svg
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#27406e"/>
|
||||||
|
<stop offset="0.55" stop-color="#172f4f"/>
|
||||||
|
<stop offset="1" stop-color="#0d1730"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.42" r="0.6">
|
||||||
|
<stop offset="0" stop-color="#3a5fa0" stop-opacity="0.55"/>
|
||||||
|
<stop offset="1" stop-color="#3a5fa0" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="ivory" x1="0.2" y1="0.05" x2="0.85" y2="0.95">
|
||||||
|
<stop offset="0" stop-color="#fffdf6"/>
|
||||||
|
<stop offset="0.55" stop-color="#f3e8d2"/>
|
||||||
|
<stop offset="1" stop-color="#d8c39a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="ds" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#000000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect x="0" y="0" width="1024" height="1024" rx="224" fill="url(#bg)"/>
|
||||||
|
<rect x="0" y="0" width="1024" height="1024" rx="224" fill="url(#glow)"/>
|
||||||
|
|
||||||
|
<g filter="url(#ds)">
|
||||||
|
<!-- single bold tusk: thick rounded root at upper-right, tapering along a
|
||||||
|
gentle curve to a sharp tip at lower-left -->
|
||||||
|
<path d="M 712 408
|
||||||
|
C 720 600, 560 720, 322 742
|
||||||
|
C 470 700, 600 470, 628 318
|
||||||
|
A 60 60 0 0 1 712 408 Z"
|
||||||
|
fill="url(#ivory)"/>
|
||||||
|
<!-- soft sheen along the belly (upper-left edge) for ivory depth -->
|
||||||
|
<path d="M 322 742
|
||||||
|
C 470 700, 600 470, 628 318
|
||||||
|
C 612 470, 470 660, 360 726 Z"
|
||||||
|
fill="#ffffff" opacity="0.18"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 107 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 875 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
@@ -1,13 +1,14 @@
|
|||||||
use crate::commands::ai::{build_overview_context, call_chat_messages, load_ai_settings};
|
use crate::commands::ai::{build_overview_context, call_chat_messages, load_ai_settings};
|
||||||
use crate::commands::chat_tools::{
|
use crate::commands::chat_tools::{
|
||||||
find_queries_tool, get_columns_tool, list_databases_tool, list_tables_tool, save_query_tool,
|
build_sample_sql, detect_skew_tool, explain_query_tool, find_queries_tool, get_columns_tool,
|
||||||
|
list_databases_tool, list_tables_tool, profile_table_tool, save_query_tool,
|
||||||
switch_database_tool,
|
switch_database_tool,
|
||||||
};
|
};
|
||||||
use crate::commands::memory::{append_memory_core, read_memory_core};
|
use crate::commands::memory::{append_memory_core, read_memory_core};
|
||||||
use crate::commands::queries::execute_query_core;
|
use crate::commands::queries::execute_query_core;
|
||||||
use crate::error::{TuskError, TuskResult};
|
use crate::error::{TuskError, TuskResult};
|
||||||
use crate::models::ai::OllamaChatMessage;
|
use crate::models::ai::OllamaChatMessage;
|
||||||
use crate::models::chat::{ChartConfig, ChatMessage, ChatTurnResult, ContextUsage};
|
use crate::models::chat::{ChatMessage, ChatTurnResult, ContextUsage};
|
||||||
use crate::models::query_result::QueryResult;
|
use crate::models::query_result::QueryResult;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -30,11 +31,11 @@ const TEXT_TOOL_CHAR_CAP: usize = 10_000;
|
|||||||
/// is nudged to /compact. Tuned for Ollama defaults (~8K tokens at num_ctx=8192).
|
/// is nudged to /compact. Tuned for Ollama defaults (~8K tokens at num_ctx=8192).
|
||||||
/// Token estimate ≈ chars / 3 for mixed Cyrillic/ASCII content.
|
/// Token estimate ≈ chars / 3 for mixed Cyrillic/ASCII content.
|
||||||
const CONTEXT_BUDGET_CHARS_OLLAMA: u64 = 24_000;
|
const CONTEXT_BUDGET_CHARS_OLLAMA: u64 = 24_000;
|
||||||
/// Conservative default for managed providers (Fireworks). Most chat-capable
|
/// Conservative default for managed providers (Fireworks, OpenRouter). Most
|
||||||
/// Fireworks models ship with 32K–256K context windows; 384K chars (~128K tok)
|
/// chat-capable hosted models ship with 32K–256K context windows; 384K chars
|
||||||
/// is a safe floor that won't trigger false /compact nags on normal sessions
|
/// (~128K tok) is a safe floor that won't trigger false /compact nags on normal
|
||||||
/// while still flagging genuinely runaway threads.
|
/// sessions while still flagging genuinely runaway threads.
|
||||||
const CONTEXT_BUDGET_CHARS_FIREWORKS: u64 = 384_000;
|
const CONTEXT_BUDGET_CHARS_MANAGED: u64 = 384_000;
|
||||||
/// Stop the loop when the model fails the same SQL hurdle this many times in a
|
/// Stop the loop when the model fails the same SQL hurdle this many times in a
|
||||||
/// row. Beyond this, additional hops almost always burn the rest of the budget
|
/// row. Beyond this, additional hops almost always burn the rest of the budget
|
||||||
/// on identical retries; a definitive `final` with the error is more useful.
|
/// on identical retries; a definitive `final` with the error is more useful.
|
||||||
@@ -55,9 +56,15 @@ enum AgentAction {
|
|||||||
Remember { note: String },
|
Remember { note: String },
|
||||||
SaveQuery { name: String, sql: String },
|
SaveQuery { name: String, sql: String },
|
||||||
FindQueries { text: String },
|
FindQueries { text: String },
|
||||||
MakeChart { config: ChartConfig },
|
ProfileTable { table: String },
|
||||||
|
SampleData { table: String, limit: u32 },
|
||||||
|
ExplainQuery { sql: String },
|
||||||
|
DetectSkew { table: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SAMPLE_DATA_DEFAULT_LIMIT: u32 = 50;
|
||||||
|
const SAMPLE_DATA_MAX_LIMIT: u32 = 200;
|
||||||
|
|
||||||
/// Parse the model's JSON response. Accepts both shapes the model tends to emit:
|
/// Parse the model's JSON response. Accepts both shapes the model tends to emit:
|
||||||
/// {"action":"X","field":"..."} — flat (matches our prompt)
|
/// {"action":"X","field":"..."} — flat (matches our prompt)
|
||||||
/// {"action":"X","input":{"field":"..."}} — nested (common tool-use convention)
|
/// {"action":"X","input":{"field":"..."}} — nested (common tool-use convention)
|
||||||
@@ -157,60 +164,55 @@ fn parse_agent_action(raw: &str) -> Result<AgentAction, String> {
|
|||||||
}
|
}
|
||||||
Ok(AgentAction::FindQueries { text })
|
Ok(AgentAction::FindQueries { text })
|
||||||
}
|
}
|
||||||
"make_chart" => {
|
"profile_table" => {
|
||||||
let chart_type = lookup("chart_type")
|
let table = lookup("table")
|
||||||
.or_else(|| lookup("type"))
|
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| "make_chart missing `chart_type`".to_string())?
|
.ok_or_else(|| "profile_table missing `table`".to_string())?
|
||||||
.trim()
|
|
||||||
.to_lowercase();
|
|
||||||
if !["bar", "line", "area", "pie"].contains(&chart_type.as_str()) {
|
|
||||||
return Err(format!(
|
|
||||||
"make_chart `chart_type` must be one of: bar, line, area, pie. Got: {}",
|
|
||||||
chart_type
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let x = lookup("x")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| "make_chart missing `x` column".to_string())?
|
|
||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
let y = lookup("y")
|
if table.is_empty() {
|
||||||
.and_then(|v| v.as_str())
|
return Err("profile_table `table` must not be empty".into());
|
||||||
.ok_or_else(|| "make_chart missing `y` column".to_string())?
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
if x.is_empty() || y.is_empty() {
|
|
||||||
return Err("make_chart `x` and `y` must not be empty".into());
|
|
||||||
}
|
}
|
||||||
let group = lookup("group")
|
Ok(AgentAction::ProfileTable { table })
|
||||||
.and_then(|v| v.as_str())
|
}
|
||||||
.map(|s| s.trim().to_string())
|
"sample_data" => {
|
||||||
.filter(|s| !s.is_empty());
|
let table = lookup("table")
|
||||||
let title = lookup("title")
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|v| v.as_str())
|
.ok_or_else(|| "sample_data missing `table`".to_string())?
|
||||||
.map(|s| s.trim().to_string())
|
.trim()
|
||||||
.filter(|s| !s.is_empty());
|
.to_string();
|
||||||
let orientation = lookup("orientation")
|
if table.is_empty() {
|
||||||
.and_then(|v| v.as_str())
|
return Err("sample_data `table` must not be empty".into());
|
||||||
.map(|s| s.trim().to_lowercase())
|
}
|
||||||
.filter(|s| !s.is_empty());
|
let limit = lookup("limit")
|
||||||
Ok(AgentAction::MakeChart {
|
.and_then(|v| v.as_u64())
|
||||||
config: ChartConfig {
|
.map(|n| n as u32)
|
||||||
chart_type,
|
.unwrap_or(SAMPLE_DATA_DEFAULT_LIMIT)
|
||||||
x,
|
.clamp(1, SAMPLE_DATA_MAX_LIMIT);
|
||||||
y,
|
Ok(AgentAction::SampleData { table, limit })
|
||||||
group,
|
}
|
||||||
title,
|
"explain_query" => {
|
||||||
orientation,
|
let sql = lookup("sql")
|
||||||
},
|
.and_then(|v| v.as_str())
|
||||||
})
|
.ok_or_else(|| "explain_query missing `sql`".to_string())?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if sql.is_empty() {
|
||||||
|
return Err("explain_query `sql` must not be empty".into());
|
||||||
|
}
|
||||||
|
Ok(AgentAction::ExplainQuery { sql })
|
||||||
|
}
|
||||||
|
"detect_skew" => {
|
||||||
|
let table = lookup("table")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| "detect_skew missing `table`".to_string())?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if table.is_empty() {
|
||||||
|
return Err("detect_skew `table` must not be empty".into());
|
||||||
|
}
|
||||||
|
Ok(AgentAction::DetectSkew { table })
|
||||||
}
|
}
|
||||||
// Legacy from earlier iterations — silently ignored at parse time so the
|
|
||||||
// model can recover with a different action.
|
|
||||||
"get_schema" => Err(
|
|
||||||
"get_schema is deprecated; use get_columns({\"tables\":[...]}) instead.".to_string(),
|
|
||||||
),
|
|
||||||
other => Err(format!("unknown action `{}`", other)),
|
other => Err(format!("unknown action `{}`", other)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,8 +287,17 @@ You operate as an agent in a single-tool-per-turn loop with hop limit {hops}. On
|
|||||||
{{"action":"save_query","name":"<short label>","sql":"<the SQL>"}}
|
{{"action":"save_query","name":"<short label>","sql":"<the SQL>"}}
|
||||||
Persist a non-trivial working SELECT for reuse later. Use AFTER a successful run_query when the query is likely to be re-run. Keep `name` short and descriptive (e.g. "GMV by carrier — last 30d"). The user sees these in sidebar → Saved.
|
Persist a non-trivial working SELECT for reuse later. Use AFTER a successful run_query when the query is likely to be re-run. Keep `name` short and descriptive (e.g. "GMV by carrier — last 30d"). The user sees these in sidebar → Saved.
|
||||||
|
|
||||||
{{"action":"make_chart","chart_type":"bar","x":"<col>","y":"<col>","title":"<short title>"}}
|
{{"action":"profile_table","table":"schema.table"}}
|
||||||
Visualise the LAST successful run_query result as a chart inline. `chart_type` is one of: bar, line, area, pie. `x` and `y` MUST be column names from the previous result. Optional: `group` (column for series), `orientation` ("vertical"/"horizontal", bar only). Use after run_query when the data is aggregated and would be clearer as a chart (top-N comparisons → bar; time series → line/area; proportions → pie). Skip for tiny results (≤2 rows) and giant ones (>500 rows).
|
Per-column profile: NULL fraction, distinct cardinality, min/max range, top-K values. PG/GP reads pg_stats (zero-cost; ensure ANALYZE has run). ClickHouse fires one summary query (cheap on MergeTree). Use BEFORE writing aggregations to spot pseudo-enums, NULL-heavy columns, or skewed distributions.
|
||||||
|
|
||||||
|
{{"action":"sample_data","table":"schema.table","limit":50}}
|
||||||
|
Random row sample (default 50, max 200). PG/GP uses TABLESAMPLE BERNOULLI when reltuples > 0, else ORDER BY random(). CH uses SAMPLE 0.01 on MergeTree with a sampling key, else ORDER BY rand(). Use to eyeball value shape BEFORE writing filters; cheaper than `SELECT * LIMIT N` on huge tables.
|
||||||
|
|
||||||
|
{{"action":"explain_query","sql":"SELECT ..."}}
|
||||||
|
Run EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS) on PG/GP, EXPLAIN PLAN on CH. Reports root node, planning + execution time, seq-scanned tables, spilled sorts, est-vs-actual row skew, Greenplum Motions. Use AFTER a slow run_query.
|
||||||
|
|
||||||
|
{{"action":"detect_skew","table":"schema.table"}}
|
||||||
|
Greenplum-only: counts rows per gp_segment_id and reports max/min/avg + skew ratio. Ratio > 1.5 ⇒ uneven distribution; suggests revisiting DISTRIBUTED BY. Soft-errors on PG/CH.
|
||||||
|
|
||||||
{{"action":"final","text":"..."}}
|
{{"action":"final","text":"..."}}
|
||||||
End the turn with a plain-language answer for the user. Do NOT repeat the result table — the UI shows it. Mention caveats (LIMIT, NULL filters, sampling).
|
End the turn with a plain-language answer for the user. Do NOT repeat the result table — the UI shows it. Mention caveats (LIMIT, NULL filters, sampling).
|
||||||
@@ -296,6 +307,8 @@ WORKFLOW
|
|||||||
2. For non-trivial requests, run `find_queries({{text}})` once to check if a saved query already answers the question.
|
2. For non-trivial requests, run `find_queries({{text}})` once to check if a saved query already answers the question.
|
||||||
3. Pick candidate tables from the OVERVIEW (active DB) or call list_tables if you need other DBs.
|
3. Pick candidate tables from the OVERVIEW (active DB) or call list_tables if you need other DBs.
|
||||||
4. If a candidate's columns are unknown, call get_columns FIRST. NEVER invent columns.
|
4. If a candidate's columns are unknown, call get_columns FIRST. NEVER invent columns.
|
||||||
|
4a. If the user asks about value shape (cardinality, NULL rates, top values), prefer `profile_table` over a hand-written run_query. To eyeball actual rows, prefer `sample_data` over `LIMIT 100`.
|
||||||
|
4b. If the user reports a slow query or asks why something takes long, run `explain_query` on it. On Greenplum, if a single table appears unbalanced, check `detect_skew`.
|
||||||
5. If the user's data lives in a different DB and engine is PostgreSQL, switch_database first.
|
5. If the user's data lives in a different DB and engine is PostgreSQL, switch_database first.
|
||||||
6. Execute run_query.
|
6. Execute run_query.
|
||||||
7. If you discovered something non-obvious (semantics, gotcha, business rule that isn't visible from the schema alone), call `remember` BEFORE `final`. Future sessions will see your notes here.
|
7. If you discovered something non-obvious (semantics, gotcha, business rule that isn't visible from the schema alone), call `remember` BEFORE `final`. Future sessions will see your notes here.
|
||||||
@@ -415,9 +428,6 @@ fn build_history(
|
|||||||
content: serde_json::json!({ "action": "final", "text": text }).to_string(),
|
content: serde_json::json!({ "action": "final", "text": text }).to_string(),
|
||||||
}),
|
}),
|
||||||
ChatMessage::ToolCall { tool, input_json, .. } => {
|
ChatMessage::ToolCall { tool, input_json, .. } => {
|
||||||
if tool == "get_schema" {
|
|
||||||
continue; // legacy
|
|
||||||
}
|
|
||||||
let mut envelope = serde_json::Map::new();
|
let mut envelope = serde_json::Map::new();
|
||||||
envelope.insert("action".to_string(), Value::String(tool.clone()));
|
envelope.insert("action".to_string(), Value::String(tool.clone()));
|
||||||
if let Ok(Value::Object(input)) = serde_json::from_str::<Value>(input_json) {
|
if let Ok(Value::Object(input)) = serde_json::from_str::<Value>(input_json) {
|
||||||
@@ -437,9 +447,6 @@ fn build_history(
|
|||||||
result,
|
result,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
if tool == "get_schema" {
|
|
||||||
continue; // legacy
|
|
||||||
}
|
|
||||||
let payload = match tool.as_str() {
|
let payload = match tool.as_str() {
|
||||||
"run_query" => {
|
"run_query" => {
|
||||||
if *is_error {
|
if *is_error {
|
||||||
@@ -521,7 +528,7 @@ async fn provider_budget_chars(state: &AppState, app: &AppHandle) -> u64 {
|
|||||||
use crate::models::ai::AiProvider;
|
use crate::models::ai::AiProvider;
|
||||||
match load_ai_settings(app, state).await {
|
match load_ai_settings(app, state).await {
|
||||||
Ok(s) => match s.provider {
|
Ok(s) => match s.provider {
|
||||||
AiProvider::Fireworks => CONTEXT_BUDGET_CHARS_FIREWORKS,
|
AiProvider::Fireworks | AiProvider::OpenRouter => CONTEXT_BUDGET_CHARS_MANAGED,
|
||||||
_ => CONTEXT_BUDGET_CHARS_OLLAMA,
|
_ => CONTEXT_BUDGET_CHARS_OLLAMA,
|
||||||
},
|
},
|
||||||
Err(_) => CONTEXT_BUDGET_CHARS_OLLAMA,
|
Err(_) => CONTEXT_BUDGET_CHARS_OLLAMA,
|
||||||
@@ -597,7 +604,10 @@ pub async fn chat_send(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_run_query = matches!(&action, AgentAction::RunQuery { .. });
|
let is_run_query = matches!(
|
||||||
|
&action,
|
||||||
|
AgentAction::RunQuery { .. } | AgentAction::SampleData { .. }
|
||||||
|
);
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
AgentAction::Final { text } => {
|
AgentAction::Final { text } => {
|
||||||
@@ -742,91 +752,90 @@ pub async fn chat_send(
|
|||||||
);
|
);
|
||||||
push_tool_result(&mut new_messages, &mut working, result);
|
push_tool_result(&mut new_messages, &mut working, result);
|
||||||
}
|
}
|
||||||
AgentAction::MakeChart { config } => {
|
AgentAction::ProfileTable { table } => {
|
||||||
let config_json = serde_json::to_string(&config).unwrap_or_else(|_| "{}".into());
|
|
||||||
push_tool_call(
|
push_tool_call(
|
||||||
&mut new_messages,
|
&mut new_messages,
|
||||||
&mut working,
|
&mut working,
|
||||||
"make_chart",
|
"profile_table",
|
||||||
config_json.clone(),
|
serde_json::json!({ "table": &table }).to_string(),
|
||||||
);
|
);
|
||||||
|
let result = run_text_tool(
|
||||||
let result_msg = match last_successful_query_result(&working) {
|
profile_table_tool(&state, &connection_id, &table).await,
|
||||||
None => ChatMessage::ToolResult {
|
"profile_table",
|
||||||
id: new_id("res"),
|
);
|
||||||
tool: "make_chart".to_string(),
|
push_tool_result(&mut new_messages, &mut working, result);
|
||||||
is_error: true,
|
}
|
||||||
text: Some(
|
AgentAction::ExplainQuery { sql } => {
|
||||||
"make_chart needs a successful run_query result above it. Run a SELECT first, then call make_chart."
|
push_tool_call(
|
||||||
.to_string(),
|
&mut new_messages,
|
||||||
),
|
&mut working,
|
||||||
result: None,
|
"explain_query",
|
||||||
created_at: now_ms(),
|
serde_json::json!({ "sql": &sql }).to_string(),
|
||||||
},
|
);
|
||||||
Some(qr) => {
|
let result = run_text_tool(
|
||||||
if !qr.columns.iter().any(|c| c == &config.x) {
|
explain_query_tool(&state, &connection_id, &sql).await,
|
||||||
|
"explain_query",
|
||||||
|
);
|
||||||
|
push_tool_result(&mut new_messages, &mut working, result);
|
||||||
|
}
|
||||||
|
AgentAction::DetectSkew { table } => {
|
||||||
|
push_tool_call(
|
||||||
|
&mut new_messages,
|
||||||
|
&mut working,
|
||||||
|
"detect_skew",
|
||||||
|
serde_json::json!({ "table": &table }).to_string(),
|
||||||
|
);
|
||||||
|
let result = run_text_tool(
|
||||||
|
detect_skew_tool(&state, &connection_id, &table).await,
|
||||||
|
"detect_skew",
|
||||||
|
);
|
||||||
|
push_tool_result(&mut new_messages, &mut working, result);
|
||||||
|
}
|
||||||
|
AgentAction::SampleData { table, limit } => {
|
||||||
|
push_tool_call(
|
||||||
|
&mut new_messages,
|
||||||
|
&mut working,
|
||||||
|
"sample_data",
|
||||||
|
serde_json::json!({ "table": &table, "limit": limit }).to_string(),
|
||||||
|
);
|
||||||
|
let outcome = match build_sample_sql(&state, &connection_id, &table, limit).await {
|
||||||
|
Ok(sql) => match execute_query_core(&state, &connection_id, &sql).await {
|
||||||
|
Ok(qr) => {
|
||||||
|
consecutive_query_errors = 0;
|
||||||
ChatMessage::ToolResult {
|
ChatMessage::ToolResult {
|
||||||
id: new_id("res"),
|
id: new_id("res"),
|
||||||
tool: "make_chart".to_string(),
|
tool: "sample_data".to_string(),
|
||||||
is_error: true,
|
|
||||||
text: Some(format!(
|
|
||||||
"x column `{}` is not in the last result. Available: {}.",
|
|
||||||
config.x,
|
|
||||||
qr.columns.join(", ")
|
|
||||||
)),
|
|
||||||
result: None,
|
|
||||||
created_at: now_ms(),
|
|
||||||
}
|
|
||||||
} else if !qr.columns.iter().any(|c| c == &config.y) {
|
|
||||||
ChatMessage::ToolResult {
|
|
||||||
id: new_id("res"),
|
|
||||||
tool: "make_chart".to_string(),
|
|
||||||
is_error: true,
|
|
||||||
text: Some(format!(
|
|
||||||
"y column `{}` is not in the last result. Available: {}.",
|
|
||||||
config.y,
|
|
||||||
qr.columns.join(", ")
|
|
||||||
)),
|
|
||||||
result: None,
|
|
||||||
created_at: now_ms(),
|
|
||||||
}
|
|
||||||
} else if let Some(group) = &config.group {
|
|
||||||
if !qr.columns.iter().any(|c| c == group) {
|
|
||||||
ChatMessage::ToolResult {
|
|
||||||
id: new_id("res"),
|
|
||||||
tool: "make_chart".to_string(),
|
|
||||||
is_error: true,
|
|
||||||
text: Some(format!(
|
|
||||||
"group column `{}` is not in the last result. Available: {}.",
|
|
||||||
group,
|
|
||||||
qr.columns.join(", ")
|
|
||||||
)),
|
|
||||||
result: None,
|
|
||||||
created_at: now_ms(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ChatMessage::ToolResult {
|
|
||||||
id: new_id("res"),
|
|
||||||
tool: "make_chart".to_string(),
|
|
||||||
is_error: false,
|
|
||||||
text: Some(config_json.clone()),
|
|
||||||
result: Some(qr),
|
|
||||||
created_at: now_ms(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ChatMessage::ToolResult {
|
|
||||||
id: new_id("res"),
|
|
||||||
tool: "make_chart".to_string(),
|
|
||||||
is_error: false,
|
is_error: false,
|
||||||
text: Some(config_json.clone()),
|
text: None,
|
||||||
result: Some(qr),
|
result: Some(qr),
|
||||||
created_at: now_ms(),
|
created_at: now_ms(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
consecutive_query_errors += 1;
|
||||||
|
ChatMessage::ToolResult {
|
||||||
|
id: new_id("res"),
|
||||||
|
tool: "sample_data".to_string(),
|
||||||
|
is_error: true,
|
||||||
|
text: Some(format_db_error(&e)),
|
||||||
|
result: None,
|
||||||
|
created_at: now_ms(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
consecutive_query_errors += 1;
|
||||||
|
ChatMessage::ToolResult {
|
||||||
|
id: new_id("res"),
|
||||||
|
tool: "sample_data".to_string(),
|
||||||
|
is_error: true,
|
||||||
|
text: Some(format_db_error(&e)),
|
||||||
|
result: None,
|
||||||
|
created_at: now_ms(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
push_tool_result(&mut new_messages, &mut working, result_msg);
|
push_tool_result(&mut new_messages, &mut working, outcome);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -982,26 +991,6 @@ fn format_db_error(e: &TuskError) -> String {
|
|||||||
e.to_string()
|
e.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Locate the most recent SUCCESSFUL run_query in the working thread and
|
|
||||||
/// return its full QueryResult. Used by make_chart to attach data to a chart
|
|
||||||
/// directive without relying on the model to re-send it.
|
|
||||||
fn last_successful_query_result(messages: &[ChatMessage]) -> Option<QueryResult> {
|
|
||||||
for m in messages.iter().rev() {
|
|
||||||
if let ChatMessage::ToolResult {
|
|
||||||
tool,
|
|
||||||
is_error: false,
|
|
||||||
result: Some(qr),
|
|
||||||
..
|
|
||||||
} = m
|
|
||||||
{
|
|
||||||
if tool == "run_query" {
|
|
||||||
return Some(qr.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pull the most recent run_query error text from the working thread, so the
|
/// Pull the most recent run_query error text from the working thread, so the
|
||||||
/// post-loop "I gave up" summary can quote concrete errors back to the user.
|
/// post-loop "I gave up" summary can quote concrete errors back to the user.
|
||||||
fn last_run_query_error(messages: &[ChatMessage]) -> Option<String> {
|
fn last_run_query_error(messages: &[ChatMessage]) -> Option<String> {
|
||||||
@@ -1484,119 +1473,6 @@ mod tests {
|
|||||||
assert!(last_run_query_error(&msgs).is_none());
|
assert!(last_run_query_error(&msgs).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_make_chart_minimal() {
|
|
||||||
let a = parse_agent_action(
|
|
||||||
r#"{"action":"make_chart","chart_type":"bar","x":"carrier","y":"trips"}"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
match a {
|
|
||||||
AgentAction::MakeChart { config } => {
|
|
||||||
assert_eq!(config.chart_type, "bar");
|
|
||||||
assert_eq!(config.x, "carrier");
|
|
||||||
assert_eq!(config.y, "trips");
|
|
||||||
assert!(config.group.is_none());
|
|
||||||
assert!(config.title.is_none());
|
|
||||||
}
|
|
||||||
_ => panic!("wrong variant"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_make_chart_with_group_and_title() {
|
|
||||||
let a = parse_agent_action(
|
|
||||||
r#"{"action":"make_chart","chart_type":"line","x":"month","y":"revenue","group":"region","title":"Revenue"}"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
match a {
|
|
||||||
AgentAction::MakeChart { config } => {
|
|
||||||
assert_eq!(config.group.as_deref(), Some("region"));
|
|
||||||
assert_eq!(config.title.as_deref(), Some("Revenue"));
|
|
||||||
}
|
|
||||||
_ => panic!("wrong variant"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn make_chart_accepts_alternative_field_name_type() {
|
|
||||||
// Some models emit `type` instead of `chart_type`.
|
|
||||||
let a = parse_agent_action(
|
|
||||||
r#"{"action":"make_chart","type":"pie","x":"label","y":"value"}"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
match a {
|
|
||||||
AgentAction::MakeChart { config } => assert_eq!(config.chart_type, "pie"),
|
|
||||||
_ => panic!("wrong variant"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_make_chart_with_unknown_chart_type() {
|
|
||||||
let r = parse_agent_action(
|
|
||||||
r#"{"action":"make_chart","chart_type":"radar","x":"a","y":"b"}"#,
|
|
||||||
);
|
|
||||||
assert!(r.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_make_chart_missing_x_or_y() {
|
|
||||||
assert!(parse_agent_action(r#"{"action":"make_chart","chart_type":"bar","y":"a"}"#).is_err());
|
|
||||||
assert!(parse_agent_action(r#"{"action":"make_chart","chart_type":"bar","x":"a"}"#).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn last_successful_query_result_finds_recent() {
|
|
||||||
use crate::models::query_result::QueryResult;
|
|
||||||
let qr = QueryResult {
|
|
||||||
columns: vec!["a".into()],
|
|
||||||
types: vec!["INT4".into()],
|
|
||||||
rows: vec![vec![Value::Number(1.into())]],
|
|
||||||
row_count: 1,
|
|
||||||
execution_time_ms: 1,
|
|
||||||
};
|
|
||||||
let msgs = vec![
|
|
||||||
ChatMessage::ToolResult {
|
|
||||||
id: "r1".into(),
|
|
||||||
tool: "run_query".into(),
|
|
||||||
is_error: false,
|
|
||||||
text: None,
|
|
||||||
result: Some(qr.clone()),
|
|
||||||
created_at: 1,
|
|
||||||
},
|
|
||||||
ChatMessage::ToolResult {
|
|
||||||
id: "r2".into(),
|
|
||||||
tool: "run_query".into(),
|
|
||||||
is_error: true,
|
|
||||||
text: Some("oops".into()),
|
|
||||||
result: None,
|
|
||||||
created_at: 2,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let found = last_successful_query_result(&msgs).expect("ok");
|
|
||||||
assert_eq!(found.columns, vec!["a".to_string()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn last_successful_query_result_skips_non_run_query() {
|
|
||||||
use crate::models::query_result::QueryResult;
|
|
||||||
let qr = QueryResult {
|
|
||||||
columns: vec!["a".into()],
|
|
||||||
types: vec!["INT4".into()],
|
|
||||||
rows: vec![],
|
|
||||||
row_count: 0,
|
|
||||||
execution_time_ms: 0,
|
|
||||||
};
|
|
||||||
let msgs = vec![ChatMessage::ToolResult {
|
|
||||||
id: "r1".into(),
|
|
||||||
tool: "list_tables".into(),
|
|
||||||
is_error: false,
|
|
||||||
text: Some("public.x".into()),
|
|
||||||
result: Some(qr),
|
|
||||||
created_at: 1,
|
|
||||||
}];
|
|
||||||
assert!(last_successful_query_result(&msgs).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_thread_for_summary_includes_roles_and_skips_rows() {
|
fn render_thread_for_summary_includes_roles_and_skips_rows() {
|
||||||
let msgs = vec![
|
let msgs = vec![
|
||||||
@@ -1625,11 +1501,6 @@ mod tests {
|
|||||||
assert!(!rendered.contains("alice"));
|
assert!(!rendered.contains("alice"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_legacy_get_schema() {
|
|
||||||
assert!(parse_agent_action(r#"{"action":"get_schema"}"#).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn truncates_long_cell() {
|
fn truncates_long_cell() {
|
||||||
let long = "a".repeat(CELL_CHAR_CAP + 50);
|
let long = "a".repeat(CELL_CHAR_CAP + 50);
|
||||||
|
|||||||
@@ -6,14 +6,18 @@
|
|||||||
|
|
||||||
use crate::commands::ai::{
|
use crate::commands::ai::{
|
||||||
fetch_column_comments, fetch_columns, fetch_enum_types, fetch_foreign_keys_raw,
|
fetch_column_comments, fetch_columns, fetch_enum_types, fetch_foreign_keys_raw,
|
||||||
fetch_table_comments, fetch_unique_constraints, format_table_block, ColumnInfo,
|
fetch_gp_table_extras, fetch_table_comments, fetch_unique_constraints, format_table_block,
|
||||||
|
ColumnInfo,
|
||||||
};
|
};
|
||||||
use crate::commands::connections::{load_connection_config, switch_database_core};
|
use crate::commands::connections::{load_connection_config, switch_database_core};
|
||||||
|
use crate::commands::queries::execute_query_core;
|
||||||
use crate::commands::saved_queries::{list_saved_queries_core, save_query_core};
|
use crate::commands::saved_queries::{list_saved_queries_core, save_query_core};
|
||||||
use crate::commands::schema::{list_databases_core, list_tables_core};
|
use crate::commands::schema::{list_databases_core, list_tables_core};
|
||||||
|
use crate::db::sql_guard::ensure_readonly_sql;
|
||||||
use crate::error::{TuskError, TuskResult};
|
use crate::error::{TuskError, TuskResult};
|
||||||
use crate::models::saved_queries::SavedQuery;
|
use crate::models::saved_queries::SavedQuery;
|
||||||
use crate::state::{AppState, CachedVec, DbFlavor};
|
use crate::state::{AppState, CachedVec, DbFlavor};
|
||||||
|
use crate::utils::escape_ident;
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -219,6 +223,8 @@ async fn get_columns_postgres(
|
|||||||
requested: &[(String, String, String)],
|
requested: &[(String, String, String)],
|
||||||
) -> TuskResult<String> {
|
) -> TuskResult<String> {
|
||||||
let pool = state.get_pool(connection_id).await?;
|
let pool = state.get_pool(connection_id).await?;
|
||||||
|
let is_greenplum = matches!(state.get_flavor(connection_id).await, DbFlavor::Greenplum);
|
||||||
|
let gp_major = state.get_gp_major(connection_id).await.unwrap_or(7);
|
||||||
|
|
||||||
let (col_res, fk_res, enum_res, tbl_comm_res, col_comm_res, unique_res) = tokio::join!(
|
let (col_res, fk_res, enum_res, tbl_comm_res, col_comm_res, unique_res) = tokio::join!(
|
||||||
fetch_columns(&pool),
|
fetch_columns(&pool),
|
||||||
@@ -234,6 +240,11 @@ async fn get_columns_postgres(
|
|||||||
let tbl_comments = tbl_comm_res.unwrap_or_default();
|
let tbl_comments = tbl_comm_res.unwrap_or_default();
|
||||||
let col_comments = col_comm_res.unwrap_or_default();
|
let col_comments = col_comm_res.unwrap_or_default();
|
||||||
let uniques = unique_res.unwrap_or_default();
|
let uniques = unique_res.unwrap_or_default();
|
||||||
|
let gp_extras = if is_greenplum {
|
||||||
|
Some(fetch_gp_table_extras(&pool, gp_major).await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Build (schema, table) → Vec<ColumnInfo>
|
// Build (schema, table) → Vec<ColumnInfo>
|
||||||
let mut by_table: BTreeMap<(String, String), Vec<ColumnInfo>> = BTreeMap::new();
|
let mut by_table: BTreeMap<(String, String), Vec<ColumnInfo>> = BTreeMap::new();
|
||||||
@@ -282,6 +293,7 @@ async fn get_columns_postgres(
|
|||||||
&unique_map,
|
&unique_map,
|
||||||
&varchar_values,
|
&varchar_values,
|
||||||
&jsonb_keys,
|
&jsonb_keys,
|
||||||
|
gp_extras.as_ref(),
|
||||||
&mut output,
|
&mut output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -556,3 +568,692 @@ pub async fn find_queries_tool(
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// profile_table (PR2 — data-engineering tool)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PROFILE_TABLE_MAX_COLUMNS: usize = 30;
|
||||||
|
const PROFILE_TABLE_TOPK: usize = 5;
|
||||||
|
|
||||||
|
pub async fn profile_table_tool(
|
||||||
|
state: &AppState,
|
||||||
|
connection_id: &str,
|
||||||
|
table: &str,
|
||||||
|
) -> TuskResult<String> {
|
||||||
|
let active_db = active_db_name(state, connection_id).await.unwrap_or_default();
|
||||||
|
let (schema, tbl, _raw) = normalise_table_ref(table, &active_db);
|
||||||
|
let flavor = state.get_flavor(connection_id).await;
|
||||||
|
match flavor {
|
||||||
|
DbFlavor::PostgreSQL | DbFlavor::Greenplum => {
|
||||||
|
profile_table_postgres(state, connection_id, &schema, &tbl).await
|
||||||
|
}
|
||||||
|
DbFlavor::ClickHouse => profile_table_clickhouse(state, connection_id, &schema, &tbl).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn profile_table_postgres(
|
||||||
|
state: &AppState,
|
||||||
|
connection_id: &str,
|
||||||
|
schema: &str,
|
||||||
|
table: &str,
|
||||||
|
) -> TuskResult<String> {
|
||||||
|
let pool = state.get_pool(connection_id).await?;
|
||||||
|
|
||||||
|
let exists = sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT 1 FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||||
|
WHERE n.nspname = $1 AND c.relname = $2 LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(schema)
|
||||||
|
.bind(table)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
if exists.is_none() {
|
||||||
|
return Err(TuskError::Custom(format!(
|
||||||
|
"Table '{}.{}' does not exist (or no privileges).",
|
||||||
|
schema, table
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_analyze: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
|
||||||
|
"SELECT GREATEST(last_analyze, last_autoanalyze) FROM pg_stat_user_tables \
|
||||||
|
WHERE schemaname = $1 AND relname = $2",
|
||||||
|
)
|
||||||
|
.bind(schema)
|
||||||
|
.bind(table)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let stat_rows = sqlx::query(
|
||||||
|
"SELECT attname, null_frac, n_distinct, \
|
||||||
|
most_common_vals::text, most_common_freqs, histogram_bounds::text \
|
||||||
|
FROM pg_stats \
|
||||||
|
WHERE schemaname = $1 AND tablename = $2 \
|
||||||
|
ORDER BY attname",
|
||||||
|
)
|
||||||
|
.bind(schema)
|
||||||
|
.bind(table)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let mut out = format!("PROFILE {}.{}\n", schema, table);
|
||||||
|
match last_analyze {
|
||||||
|
Some(ts) => out.push_str(&format!("Last ANALYZE: {}\n", ts.to_rfc3339())),
|
||||||
|
None => out.push_str("Last ANALYZE: never\n"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if stat_rows.is_empty() {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"\nNo statistics in pg_stats. Run: ANALYZE {}.{};\n",
|
||||||
|
escape_ident(schema),
|
||||||
|
escape_ident(table)
|
||||||
|
));
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = stat_rows.len();
|
||||||
|
let take = total.min(PROFILE_TABLE_MAX_COLUMNS);
|
||||||
|
out.push_str(&format!("\n{} columns with stats\n", total));
|
||||||
|
|
||||||
|
for r in stat_rows.iter().take(take) {
|
||||||
|
let attname: String = r.get(0);
|
||||||
|
let null_frac: f32 = r.try_get(1).unwrap_or(0.0);
|
||||||
|
let n_distinct: f32 = r.try_get(2).unwrap_or(0.0);
|
||||||
|
let mcv_text: Option<String> = r.try_get(3).ok();
|
||||||
|
let mcf_arr: Option<Vec<f32>> = r.try_get(4).ok();
|
||||||
|
let hist_text: Option<String> = r.try_get(5).ok();
|
||||||
|
|
||||||
|
out.push_str(&format!("\n {}:\n", attname));
|
||||||
|
out.push_str(&format!(" null_frac: {:.4}\n", null_frac));
|
||||||
|
if n_distinct < 0.0 {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" n_distinct: {:.3} (ratio of total rows)\n",
|
||||||
|
-n_distinct
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!(" n_distinct: {}\n", n_distinct as i64));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(text) = hist_text.as_deref() {
|
||||||
|
let bounds = parse_pg_array_text_local(text);
|
||||||
|
if let (Some(min), Some(max)) = (bounds.first(), bounds.last()) {
|
||||||
|
out.push_str(&format!(" range: {} … {}\n", min, max));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(text) = mcv_text.as_deref() {
|
||||||
|
let vals = parse_pg_array_text_local(text);
|
||||||
|
if !vals.is_empty() {
|
||||||
|
let freqs = mcf_arr.unwrap_or_default();
|
||||||
|
let pairs: Vec<String> = vals
|
||||||
|
.iter()
|
||||||
|
.take(PROFILE_TABLE_TOPK)
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, v)| match freqs.get(i) {
|
||||||
|
Some(f) => format!("{}({:.3})", v, f),
|
||||||
|
None => v.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.push_str(&format!(" top: {}\n", pairs.join(", ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if total > take {
|
||||||
|
out.push_str(&format!("\n…and {} more columns\n", total - take));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local pg-array parser used by profile_table; mirrors `parse_pg_array_text` in ai.rs
|
||||||
|
/// but kept local to avoid importing a private helper.
|
||||||
|
fn parse_pg_array_text_local(s: &str) -> Vec<String> {
|
||||||
|
let s = s.trim();
|
||||||
|
let s = s.strip_prefix('{').unwrap_or(s);
|
||||||
|
let s = s.strip_suffix('}').unwrap_or(s);
|
||||||
|
if s.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut in_quotes = false;
|
||||||
|
let mut chars = s.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
match c {
|
||||||
|
'"' if !in_quotes => in_quotes = true,
|
||||||
|
'"' if in_quotes => {
|
||||||
|
if chars.peek() == Some(&'"') {
|
||||||
|
cur.push('"');
|
||||||
|
chars.next();
|
||||||
|
} else {
|
||||||
|
in_quotes = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
',' if !in_quotes => {
|
||||||
|
out.push(std::mem::take(&mut cur));
|
||||||
|
}
|
||||||
|
'\\' if in_quotes => {
|
||||||
|
if let Some(next) = chars.next() {
|
||||||
|
cur.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => cur.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !cur.is_empty() || s.ends_with(',') {
|
||||||
|
out.push(cur);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn profile_table_clickhouse(
|
||||||
|
state: &AppState,
|
||||||
|
connection_id: &str,
|
||||||
|
schema: &str,
|
||||||
|
table: &str,
|
||||||
|
) -> TuskResult<String> {
|
||||||
|
let client = state.get_ch_client(connection_id).await?;
|
||||||
|
let active_db = client.database.clone();
|
||||||
|
let dbn = if schema == "public" || schema.is_empty() {
|
||||||
|
active_db
|
||||||
|
} else {
|
||||||
|
schema.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let cols_sql = format!(
|
||||||
|
"SELECT name, type FROM system.columns \
|
||||||
|
WHERE database = '{}' AND table = '{}' \
|
||||||
|
ORDER BY position LIMIT {}",
|
||||||
|
dbn.replace('\'', "\\'"),
|
||||||
|
table.replace('\'', "\\'"),
|
||||||
|
PROFILE_TABLE_MAX_COLUMNS
|
||||||
|
);
|
||||||
|
let col_rows = client.fetch_objects(&cols_sql).await?;
|
||||||
|
if col_rows.is_empty() {
|
||||||
|
return Err(TuskError::Custom(format!(
|
||||||
|
"Table '{}.{}' does not exist (or no privileges).",
|
||||||
|
dbn, table
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut select_parts: Vec<String> = vec!["count() AS rows_total".to_string()];
|
||||||
|
let mut col_names: Vec<String> = Vec::new();
|
||||||
|
let mut col_types: Vec<String> = Vec::new();
|
||||||
|
for r in &col_rows {
|
||||||
|
let name = r.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let dtype = r.get("type").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
col_names.push(name.clone());
|
||||||
|
col_types.push(dtype);
|
||||||
|
let q = name.replace('`', "``");
|
||||||
|
select_parts.push(format!("countIf(`{}` IS NULL) AS null_{}", q, col_names.len()));
|
||||||
|
select_parts.push(format!("uniqHLL12(`{}`) AS dist_{}", q, col_names.len()));
|
||||||
|
select_parts.push(format!("toString(min(`{}`)) AS min_{}", q, col_names.len()));
|
||||||
|
select_parts.push(format!("toString(max(`{}`)) AS max_{}", q, col_names.len()));
|
||||||
|
select_parts.push(format!(
|
||||||
|
"arrayStringConcat(arrayMap(x -> toString(x), topK({})(`{}`)), '|') AS top_{}",
|
||||||
|
PROFILE_TABLE_TOPK,
|
||||||
|
q,
|
||||||
|
col_names.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let agg_sql = format!(
|
||||||
|
"SELECT {} FROM `{}`.`{}`",
|
||||||
|
select_parts.join(", "),
|
||||||
|
dbn.replace('`', "``"),
|
||||||
|
table.replace('`', "``")
|
||||||
|
);
|
||||||
|
let agg_rows = client.fetch_objects(&agg_sql).await?;
|
||||||
|
let row = agg_rows
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| TuskError::Custom("ClickHouse returned no row for profile aggregate".into()))?;
|
||||||
|
|
||||||
|
let rows_total = row
|
||||||
|
.get("rows_total")
|
||||||
|
.and_then(|v| v.as_str().and_then(|s| s.parse::<i64>().ok()).or_else(|| v.as_i64()))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let mut out = format!(
|
||||||
|
"PROFILE {}.{}\nRows: {}\n{} columns profiled\n",
|
||||||
|
dbn,
|
||||||
|
table,
|
||||||
|
rows_total,
|
||||||
|
col_names.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (i, name) in col_names.iter().enumerate() {
|
||||||
|
let n = i + 1;
|
||||||
|
let nulls = row
|
||||||
|
.get(&format!("null_{}", n))
|
||||||
|
.and_then(|v| v.as_str().and_then(|s| s.parse::<i64>().ok()).or_else(|| v.as_i64()))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let dist = row
|
||||||
|
.get(&format!("dist_{}", n))
|
||||||
|
.and_then(|v| v.as_str().and_then(|s| s.parse::<i64>().ok()).or_else(|| v.as_i64()))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let min = row.get(&format!("min_{}", n)).and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let max = row.get(&format!("max_{}", n)).and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let top_raw = row.get(&format!("top_{}", n)).and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
out.push_str(&format!("\n {} ({}):\n", name, col_types[i]));
|
||||||
|
let null_frac = if rows_total > 0 {
|
||||||
|
nulls as f64 / rows_total as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
out.push_str(&format!(" null_frac: {:.4}\n", null_frac));
|
||||||
|
out.push_str(&format!(" distinct (HLL): {}\n", dist));
|
||||||
|
if !min.is_empty() || !max.is_empty() {
|
||||||
|
out.push_str(&format!(" range: {} … {}\n", min, max));
|
||||||
|
}
|
||||||
|
if !top_raw.is_empty() {
|
||||||
|
let top_vals: Vec<&str> = top_raw.split('|').take(PROFILE_TABLE_TOPK).collect();
|
||||||
|
out.push_str(&format!(" top: {}\n", top_vals.join(", ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if col_rows.len() == PROFILE_TABLE_MAX_COLUMNS {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"\n…showing first {} columns\n",
|
||||||
|
PROFILE_TABLE_MAX_COLUMNS
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// sample_data (PR2 — returns SQL string; dispatch site runs it through
|
||||||
|
// execute_query_core so the QueryResult feeds the standard renderer)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn build_sample_sql(
|
||||||
|
state: &AppState,
|
||||||
|
connection_id: &str,
|
||||||
|
table: &str,
|
||||||
|
limit: u32,
|
||||||
|
) -> TuskResult<String> {
|
||||||
|
let active_db = active_db_name(state, connection_id).await.unwrap_or_default();
|
||||||
|
let (schema, tbl, _raw) = normalise_table_ref(table, &active_db);
|
||||||
|
let flavor = state.get_flavor(connection_id).await;
|
||||||
|
match flavor {
|
||||||
|
DbFlavor::PostgreSQL | DbFlavor::Greenplum => {
|
||||||
|
build_sample_sql_postgres(state, connection_id, &schema, &tbl, limit).await
|
||||||
|
}
|
||||||
|
DbFlavor::ClickHouse => {
|
||||||
|
build_sample_sql_clickhouse(state, connection_id, &schema, &tbl, limit).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_sample_sql_postgres(
|
||||||
|
state: &AppState,
|
||||||
|
connection_id: &str,
|
||||||
|
schema: &str,
|
||||||
|
table: &str,
|
||||||
|
limit: u32,
|
||||||
|
) -> TuskResult<String> {
|
||||||
|
let pool = state.get_pool(connection_id).await?;
|
||||||
|
// pg_class.reltuples is `real` (FLOAT4); decode as f32 then widen — sqlx is
|
||||||
|
// strict and reading it directly as f64 fails with a type-mismatch error.
|
||||||
|
let reltuples: f64 = sqlx::query_scalar::<_, f32>(
|
||||||
|
"SELECT c.reltuples FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||||
|
WHERE n.nspname = $1 AND c.relname = $2",
|
||||||
|
)
|
||||||
|
.bind(schema)
|
||||||
|
.bind(table)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?
|
||||||
|
.unwrap_or(0.0) as f64;
|
||||||
|
|
||||||
|
let qualified = format!("{}.{}", escape_ident(schema), escape_ident(table));
|
||||||
|
if reltuples > 0.0 {
|
||||||
|
let target = limit as f64 * 100.0 / reltuples;
|
||||||
|
let percent = target.clamp(0.01, 100.0);
|
||||||
|
Ok(format!(
|
||||||
|
"SELECT * FROM {} TABLESAMPLE BERNOULLI({:.4}) LIMIT {}",
|
||||||
|
qualified, percent, limit
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(format!(
|
||||||
|
"SELECT * FROM {} ORDER BY random() LIMIT {}",
|
||||||
|
qualified, limit
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_sample_sql_clickhouse(
|
||||||
|
state: &AppState,
|
||||||
|
connection_id: &str,
|
||||||
|
schema: &str,
|
||||||
|
table: &str,
|
||||||
|
limit: u32,
|
||||||
|
) -> TuskResult<String> {
|
||||||
|
let client = state.get_ch_client(connection_id).await?;
|
||||||
|
let active_db = client.database.clone();
|
||||||
|
let dbn = if schema == "public" || schema.is_empty() {
|
||||||
|
active_db
|
||||||
|
} else {
|
||||||
|
schema.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let info_sql = format!(
|
||||||
|
"SELECT engine, sampling_key FROM system.tables \
|
||||||
|
WHERE database = '{}' AND name = '{}' LIMIT 1",
|
||||||
|
dbn.replace('\'', "\\'"),
|
||||||
|
table.replace('\'', "\\'")
|
||||||
|
);
|
||||||
|
let rows = client.fetch_objects(&info_sql).await.unwrap_or_default();
|
||||||
|
let (engine, sampling_key) = match rows.first() {
|
||||||
|
Some(r) => (
|
||||||
|
r.get("engine").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||||
|
r.get("sampling_key").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||||
|
),
|
||||||
|
None => (String::new(), String::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let qualified = format!(
|
||||||
|
"`{}`.`{}`",
|
||||||
|
dbn.replace('`', "``"),
|
||||||
|
table.replace('`', "``")
|
||||||
|
);
|
||||||
|
if engine.starts_with("Merge") && !sampling_key.trim().is_empty() {
|
||||||
|
Ok(format!(
|
||||||
|
"SELECT * FROM {} SAMPLE 0.01 LIMIT {}",
|
||||||
|
qualified, limit
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(format!(
|
||||||
|
"SELECT * FROM {} ORDER BY rand() LIMIT {}",
|
||||||
|
qualified, limit
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// explain_query (PR2)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn explain_query_tool(
|
||||||
|
state: &AppState,
|
||||||
|
connection_id: &str,
|
||||||
|
sql: &str,
|
||||||
|
) -> TuskResult<String> {
|
||||||
|
let trimmed = sql.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(TuskError::Custom("explain_query: sql must not be empty".into()));
|
||||||
|
}
|
||||||
|
// Validate the user's statement BEFORE prefixing EXPLAIN so the error message
|
||||||
|
// references their SQL, not the wrapper. ensure_readonly_sql also rejects any
|
||||||
|
// forbidden keywords (INSERT/UPDATE/DELETE/...) even nested under EXPLAIN.
|
||||||
|
ensure_readonly_sql(trimmed).map_err(|e| TuskError::Custom(e.to_string()))?;
|
||||||
|
|
||||||
|
let flavor = state.get_flavor(connection_id).await;
|
||||||
|
match flavor {
|
||||||
|
DbFlavor::PostgreSQL | DbFlavor::Greenplum => {
|
||||||
|
explain_query_postgres(state, connection_id, trimmed).await
|
||||||
|
}
|
||||||
|
DbFlavor::ClickHouse => explain_query_clickhouse(state, connection_id, trimmed).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn explain_query_postgres(
|
||||||
|
state: &AppState,
|
||||||
|
connection_id: &str,
|
||||||
|
sql: &str,
|
||||||
|
) -> TuskResult<String> {
|
||||||
|
let pool = state.get_pool(connection_id).await?;
|
||||||
|
let plan_sql = format!("EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS) {}", sql);
|
||||||
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
|
sqlx::query("SET TRANSACTION READ ONLY")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
let row = sqlx::query(&plan_sql)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
let raw_json: serde_json::Value = match row.try_get::<serde_json::Value, _>(0) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => {
|
||||||
|
let s: String = row.try_get(0).map_err(TuskError::Database)?;
|
||||||
|
serde_json::from_str(&s)
|
||||||
|
.map_err(|e| TuskError::Custom(format!("EXPLAIN JSON parse failed: {}", e)))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let plans = raw_json
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| TuskError::Custom("EXPLAIN JSON: expected array".into()))?;
|
||||||
|
let plan = plans.first().and_then(|p| p.get("Plan")).ok_or_else(|| {
|
||||||
|
TuskError::Custom("EXPLAIN JSON: missing top-level Plan node".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let root_node = plan.get("Node Type").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let total_cost = plan.get("Total Cost").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||||
|
let planning = plans
|
||||||
|
.first()
|
||||||
|
.and_then(|p| p.get("Planning Time").and_then(|v| v.as_f64()))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let execution = plans
|
||||||
|
.first()
|
||||||
|
.and_then(|p| p.get("Execution Time").and_then(|v| v.as_f64()))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
|
let mut seq_scans: Vec<String> = Vec::new();
|
||||||
|
let mut spilled: Vec<String> = Vec::new();
|
||||||
|
let mut motions: Vec<String> = Vec::new();
|
||||||
|
let mut max_skew: Option<(f64, String)> = None;
|
||||||
|
walk_pg_plan(plan, &mut seq_scans, &mut spilled, &mut motions, &mut max_skew);
|
||||||
|
|
||||||
|
let mut out = format!(
|
||||||
|
"PLAN root: {}, total cost {:.1}\nPlanning: {:.2} ms Execution: {:.2} ms\n",
|
||||||
|
root_node, total_cost, planning, execution
|
||||||
|
);
|
||||||
|
if !seq_scans.is_empty() {
|
||||||
|
out.push_str(&format!("Seq scans on: {}\n", seq_scans.join(", ")));
|
||||||
|
}
|
||||||
|
if !spilled.is_empty() {
|
||||||
|
out.push_str(&format!("Spilled to disk: {}\n", spilled.join(", ")));
|
||||||
|
}
|
||||||
|
if !motions.is_empty() {
|
||||||
|
out.push_str(&format!("Motions (Greenplum): {}\n", motions.join(", ")));
|
||||||
|
}
|
||||||
|
if let Some((ratio, node)) = max_skew {
|
||||||
|
if ratio >= 5.0 {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"Estimate skew: max plan/actual ratio = {:.1} on {}\n",
|
||||||
|
ratio, node
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seq_scans.is_empty() && spilled.is_empty() && motions.is_empty() {
|
||||||
|
out.push_str("No obvious red flags.\n");
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_pg_plan(
|
||||||
|
node: &serde_json::Value,
|
||||||
|
seq_scans: &mut Vec<String>,
|
||||||
|
spilled: &mut Vec<String>,
|
||||||
|
motions: &mut Vec<String>,
|
||||||
|
max_skew: &mut Option<(f64, String)>,
|
||||||
|
) {
|
||||||
|
let node_type = node.get("Node Type").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if node_type == "Seq Scan" {
|
||||||
|
let rel = node
|
||||||
|
.get("Relation Name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("?");
|
||||||
|
let schema = node
|
||||||
|
.get("Schema")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| format!("{}.", s))
|
||||||
|
.unwrap_or_default();
|
||||||
|
seq_scans.push(format!("{}{}", schema, rel));
|
||||||
|
}
|
||||||
|
if let Some(method) = node.get("Sort Method").and_then(|v| v.as_str()) {
|
||||||
|
if method.contains("disk") || method.contains("external") {
|
||||||
|
spilled.push(format!("Sort ({})", method));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if node_type.contains("Motion") {
|
||||||
|
motions.push(node_type.to_string());
|
||||||
|
}
|
||||||
|
let plan_rows = node.get("Plan Rows").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||||
|
let actual_rows = node.get("Actual Rows").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||||
|
if actual_rows > 0.0 && plan_rows > 0.0 {
|
||||||
|
let ratio = (plan_rows / actual_rows).max(actual_rows / plan_rows);
|
||||||
|
if max_skew.as_ref().map(|(r, _)| ratio > *r).unwrap_or(true) {
|
||||||
|
*max_skew = Some((ratio, node_type.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(children) = node.get("Plans").and_then(|v| v.as_array()) {
|
||||||
|
for child in children {
|
||||||
|
walk_pg_plan(child, seq_scans, spilled, motions, max_skew);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn explain_query_clickhouse(
|
||||||
|
state: &AppState,
|
||||||
|
connection_id: &str,
|
||||||
|
sql: &str,
|
||||||
|
) -> TuskResult<String> {
|
||||||
|
let client = state.get_ch_client(connection_id).await?;
|
||||||
|
let plan_sql = format!("EXPLAIN PLAN {}", sql);
|
||||||
|
let qr = client.execute_query(&plan_sql, true).await?;
|
||||||
|
if qr.rows.is_empty() {
|
||||||
|
return Ok("(empty plan)".to_string());
|
||||||
|
}
|
||||||
|
let mut out = String::from("ClickHouse plan:\n");
|
||||||
|
for row in &qr.rows {
|
||||||
|
if let Some(cell) = row.first() {
|
||||||
|
if let Some(s) = cell.as_str() {
|
||||||
|
out.push_str(s);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// detect_skew (PR2 — Greenplum-only)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn detect_skew_tool(
|
||||||
|
state: &AppState,
|
||||||
|
connection_id: &str,
|
||||||
|
table: &str,
|
||||||
|
) -> TuskResult<String> {
|
||||||
|
let flavor = state.get_flavor(connection_id).await;
|
||||||
|
if !matches!(flavor, DbFlavor::Greenplum) {
|
||||||
|
return Ok("detect_skew is only available on Greenplum connections.".to_string());
|
||||||
|
}
|
||||||
|
let active_db = active_db_name(state, connection_id).await.unwrap_or_default();
|
||||||
|
let (schema, tbl, _raw) = normalise_table_ref(table, &active_db);
|
||||||
|
|
||||||
|
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&tbl));
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT gp_segment_id, COUNT(*) AS n FROM {} GROUP BY 1 ORDER BY 1",
|
||||||
|
qualified
|
||||||
|
);
|
||||||
|
let qr = execute_query_core(state, connection_id, &sql).await?;
|
||||||
|
|
||||||
|
let mut counts: Vec<(i64, i64)> = Vec::new();
|
||||||
|
for row in &qr.rows {
|
||||||
|
let seg = row
|
||||||
|
.get(0)
|
||||||
|
.and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let n = row
|
||||||
|
.get(1)
|
||||||
|
.and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
|
||||||
|
.unwrap_or(0);
|
||||||
|
counts.push((seg, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
if counts.is_empty() {
|
||||||
|
return Ok(format!("Table {}.{} is empty.", schema, tbl));
|
||||||
|
}
|
||||||
|
|
||||||
|
let total: i64 = counts.iter().map(|(_, n)| *n).sum();
|
||||||
|
let max = counts.iter().map(|(_, n)| *n).max().unwrap_or(0);
|
||||||
|
let min = counts.iter().map(|(_, n)| *n).min().unwrap_or(0);
|
||||||
|
let avg = total as f64 / counts.len() as f64;
|
||||||
|
let ratio = if avg > 0.0 { max as f64 / avg } else { 0.0 };
|
||||||
|
|
||||||
|
let mut out = format!(
|
||||||
|
"Per-segment row distribution for {}.{}\nsegments: {} total rows: {}\nmin: {} max: {} avg: {:.0}\nskew ratio (max/avg): {:.2}",
|
||||||
|
schema,
|
||||||
|
tbl,
|
||||||
|
counts.len(),
|
||||||
|
total,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
avg,
|
||||||
|
ratio
|
||||||
|
);
|
||||||
|
if ratio > 1.5 {
|
||||||
|
out.push_str(" ⚠ uneven distribution\n");
|
||||||
|
} else {
|
||||||
|
out.push_str(" OK — within 1.5x of average\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool = state.get_pool(connection_id).await?;
|
||||||
|
if let Some(policy) = fetch_gp_distribution_for(&pool, &schema, &tbl).await {
|
||||||
|
out.push_str(&format!("\nCurrent policy: {}\n", policy));
|
||||||
|
if ratio > 1.5 {
|
||||||
|
out.push_str(
|
||||||
|
"Hint: pick a higher-cardinality column. Run profile_table to compare n_distinct.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the Greenplum DISTRIBUTED BY policy for a single table. Returns None if
|
||||||
|
/// the catalog query fails (non-GP connection, missing privileges, etc.).
|
||||||
|
async fn fetch_gp_distribution_for(
|
||||||
|
pool: &PgPool,
|
||||||
|
schema: &str,
|
||||||
|
table: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT COALESCE(\
|
||||||
|
(SELECT array_agg(a.attname ORDER BY ord.idx) \
|
||||||
|
FROM regexp_split_to_table(NULLIF(trim(p.distkey::text), ''), ' ') \
|
||||||
|
WITH ORDINALITY AS ord(attnum_str, idx) \
|
||||||
|
JOIN pg_attribute a \
|
||||||
|
ON a.attrelid = c.oid \
|
||||||
|
AND a.attnum::int = ord.attnum_str::int), \
|
||||||
|
ARRAY[]::text[] \
|
||||||
|
) AS dist_columns \
|
||||||
|
FROM gp_distribution_policy p \
|
||||||
|
JOIN pg_class c ON p.localoid = c.oid \
|
||||||
|
JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||||
|
WHERE n.nspname = $1 AND c.relname = $2",
|
||||||
|
)
|
||||||
|
.bind(schema)
|
||||||
|
.bind(table)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()?;
|
||||||
|
let cols: Vec<String> = row.try_get(0).ok()?;
|
||||||
|
Some(if cols.is_empty() {
|
||||||
|
"DISTRIBUTED RANDOMLY".to_string()
|
||||||
|
} else {
|
||||||
|
format!("DISTRIBUTED BY ({})", cols.join(", "))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ async fn close_connection(state: &AppState, id: &str) {
|
|||||||
let mut flavors = state.db_flavors.write().await;
|
let mut flavors = state.db_flavors.write().await;
|
||||||
flavors.remove(id);
|
flavors.remove(id);
|
||||||
drop(flavors);
|
drop(flavors);
|
||||||
|
state.gp_majors.write().await.remove(id);
|
||||||
state.invalidate_chat_caches_for(id).await;
|
state.invalidate_chat_caches_for(id).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +118,16 @@ pub async fn delete_connection(
|
|||||||
fs::write(&path, data)?;
|
fs::write(&path, data)?;
|
||||||
}
|
}
|
||||||
close_connection(&state, &id).await;
|
close_connection(&state, &id).await;
|
||||||
|
// Best-effort cleanup of per-connection persisted state; errors are logged
|
||||||
|
// but don't block the deletion (the connections.json is the source of truth).
|
||||||
|
if let Err(e) = crate::commands::memory::delete_memory_core(&app, &id) {
|
||||||
|
log::warn!("failed to delete memory file for {}: {}", id, e);
|
||||||
|
}
|
||||||
|
if let Err(e) =
|
||||||
|
crate::commands::saved_queries::delete_by_connection_core(&app, &id).await
|
||||||
|
{
|
||||||
|
log::warn!("failed to clean saved queries for {}: {}", id, e);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +207,19 @@ pub async fn connect(
|
|||||||
} else {
|
} else {
|
||||||
DbFlavor::PostgreSQL
|
DbFlavor::PostgreSQL
|
||||||
};
|
};
|
||||||
|
// Pull the GP major (6 or 7) from a string like
|
||||||
|
// "PostgreSQL 14.4 (Greenplum Database 7.1.0 ...)".
|
||||||
|
// GP6 and GP7 expose very different system catalogs, so downstream
|
||||||
|
// schema-introspection code branches on this.
|
||||||
|
let gp_major: Option<u8> = if flavor == DbFlavor::Greenplum {
|
||||||
|
version
|
||||||
|
.split("Greenplum Database ")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|tail| tail.split('.').next())
|
||||||
|
.and_then(|major| major.parse::<u8>().ok())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
state.pools.write().await.insert(config.id.clone(), pool);
|
state.pools.write().await.insert(config.id.clone(), pool);
|
||||||
state.read_only.write().await.insert(config.id.clone(), true);
|
state.read_only.write().await.insert(config.id.clone(), true);
|
||||||
state
|
state
|
||||||
@@ -203,6 +227,11 @@ pub async fn connect(
|
|||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
.insert(config.id.clone(), flavor);
|
.insert(config.id.clone(), flavor);
|
||||||
|
if let Some(m) = gp_major {
|
||||||
|
state.gp_majors.write().await.insert(config.id.clone(), m);
|
||||||
|
} else {
|
||||||
|
state.gp_majors.write().await.remove(&config.id);
|
||||||
|
}
|
||||||
Ok(ConnectResult { version, flavor })
|
Ok(ConnectResult { version, flavor })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,9 +136,19 @@ pub async fn get_table_data(
|
|||||||
|
|
||||||
let offset = (page.saturating_sub(1)) * page_size;
|
let offset = (page.saturating_sub(1)) * page_size;
|
||||||
|
|
||||||
|
// Greenplum AO / AOCO tables don't expose `ctid` and even on heap tables
|
||||||
|
// it isn't a reliable per-row identifier across segments — so on GP we
|
||||||
|
// skip the ctid column entirely. Edits without a PK will be rejected by
|
||||||
|
// update_row/delete_rows below with a friendly error.
|
||||||
|
let include_ctid = !matches!(flavor, DbFlavor::Greenplum);
|
||||||
|
let select_clause = if include_ctid {
|
||||||
|
"*, ctid::text"
|
||||||
|
} else {
|
||||||
|
"*"
|
||||||
|
};
|
||||||
let data_sql = format!(
|
let data_sql = format!(
|
||||||
"SELECT *, ctid::text FROM {}{}{} LIMIT {} OFFSET {}",
|
"SELECT {} FROM {}{}{} LIMIT {} OFFSET {}",
|
||||||
qualified, where_clause, order_clause, page_size, offset
|
select_clause, qualified, where_clause, order_clause, page_size, offset
|
||||||
);
|
);
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM {}{}", qualified, where_clause);
|
let count_sql = format!("SELECT COUNT(*) FROM {}{}", qualified, where_clause);
|
||||||
|
|
||||||
@@ -248,6 +258,13 @@ pub async fn update_row(
|
|||||||
let set_clause = format!("{} = $1", escape_ident(&column));
|
let set_clause = format!("{} = $1", escape_ident(&column));
|
||||||
|
|
||||||
if pk_columns.is_empty() {
|
if pk_columns.is_empty() {
|
||||||
|
if matches!(state.get_flavor(&connection_id).await, DbFlavor::Greenplum) {
|
||||||
|
return Err(TuskError::Custom(
|
||||||
|
"Greenplum doesn't expose a stable ctid (AO/AOCO tables have none, \
|
||||||
|
heap-table ctid isn't unique across segments). Inline edit requires \
|
||||||
|
a primary key — add one or use a SQL UPDATE.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
// Fallback: use ctid for row identification
|
// Fallback: use ctid for row identification
|
||||||
let ctid_val = ctid.ok_or_else(|| {
|
let ctid_val = ctid.ok_or_else(|| {
|
||||||
TuskError::Custom("Cannot update: no primary key and no ctid provided".into())
|
TuskError::Custom("Cannot update: no primary key and no ctid provided".into())
|
||||||
@@ -354,6 +371,13 @@ pub async fn delete_rows(
|
|||||||
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
if pk_columns.is_empty() {
|
if pk_columns.is_empty() {
|
||||||
|
if matches!(state.get_flavor(&connection_id).await, DbFlavor::Greenplum) {
|
||||||
|
return Err(TuskError::Custom(
|
||||||
|
"Greenplum doesn't expose a stable ctid (AO/AOCO tables have none, \
|
||||||
|
heap-table ctid isn't unique across segments). Inline delete requires \
|
||||||
|
a primary key — add one or use a SQL DELETE.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
// Fallback: use ctids for row identification
|
// Fallback: use ctids for row identification
|
||||||
let ctid_list = ctids.ok_or_else(|| {
|
let ctid_list = ctids.ok_or_else(|| {
|
||||||
TuskError::Custom("Cannot delete: no primary key and no ctids provided".into())
|
TuskError::Custom("Cannot delete: no primary key and no ctids provided".into())
|
||||||
|
|||||||
@@ -146,6 +146,21 @@ pub(crate) fn enforce_size_cap(content: &str, cap: usize) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Best-effort delete of a connection's memory file. Returns Ok(()) when the
|
||||||
|
/// file doesn't exist; only surfaces an error for actual filesystem failures.
|
||||||
|
/// Used by delete_connection to keep memory/ from filling up with orphan files.
|
||||||
|
pub(crate) fn delete_memory_core(
|
||||||
|
app: &AppHandle,
|
||||||
|
connection_id: &str,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
let path = get_memory_path(app, connection_id)?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
fs::remove_file(&path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_memory(app: AppHandle, connection_id: String) -> TuskResult<String> {
|
pub async fn get_memory(app: AppHandle, connection_id: String) -> TuskResult<String> {
|
||||||
read_memory_core(&app, &connection_id)
|
read_memory_core(&app, &connection_id)
|
||||||
|
|||||||
@@ -54,6 +54,29 @@ pub(crate) async fn save_query_core(app: &AppHandle, query: SavedQuery) -> TuskR
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop every saved-query entry tied to a specific connection_id. Entries with
|
||||||
|
/// `connection_id == None` (older "global" saves) are deliberately preserved
|
||||||
|
/// so they remain visible across all connections.
|
||||||
|
pub(crate) async fn delete_by_connection_core(
|
||||||
|
app: &AppHandle,
|
||||||
|
connection_id: &str,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
let path = get_saved_queries_path(app)?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let data = fs::read_to_string(&path)?;
|
||||||
|
let mut entries: Vec<SavedQuery> = serde_json::from_str(&data).unwrap_or_default();
|
||||||
|
let before = entries.len();
|
||||||
|
entries.retain(|e| e.connection_id.as_deref() != Some(connection_id));
|
||||||
|
if entries.len() == before {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let data = serde_json::to_string_pretty(&entries)?;
|
||||||
|
fs::write(&path, data)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_saved_queries(
|
pub async fn list_saved_queries(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
|
|||||||
@@ -111,9 +111,7 @@ pub fn run() {
|
|||||||
commands::ai::save_ai_settings,
|
commands::ai::save_ai_settings,
|
||||||
commands::ai::list_ollama_models,
|
commands::ai::list_ollama_models,
|
||||||
commands::ai::list_fireworks_models,
|
commands::ai::list_fireworks_models,
|
||||||
commands::ai::generate_sql,
|
commands::ai::list_openrouter_models,
|
||||||
commands::ai::explain_sql,
|
|
||||||
commands::ai::fix_sql_error,
|
|
||||||
// chat
|
// chat
|
||||||
commands::chat::chat_send,
|
commands::chat::chat_send,
|
||||||
commands::chat::chat_compact,
|
commands::chat::chat_compact,
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum AiProvider {
|
pub enum AiProvider {
|
||||||
#[default]
|
#[default]
|
||||||
Ollama,
|
Ollama,
|
||||||
OpenAi,
|
|
||||||
Anthropic,
|
|
||||||
Fireworks,
|
Fireworks,
|
||||||
|
OpenRouter,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a provider string, coercing legacy `openai`/`anthropic` and any
|
||||||
|
/// unknown value to `Ollama`. Keeps existing config files loadable after the
|
||||||
|
/// stub providers were removed.
|
||||||
|
impl<'de> Deserialize<'de> for AiProvider {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||||
|
let s = String::deserialize(d)?;
|
||||||
|
Ok(match s.as_str() {
|
||||||
|
"fireworks" => AiProvider::Fireworks,
|
||||||
|
"openrouter" => AiProvider::OpenRouter,
|
||||||
|
_ => AiProvider::Ollama,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -15,11 +28,9 @@ pub struct AiSettings {
|
|||||||
pub provider: AiProvider,
|
pub provider: AiProvider,
|
||||||
pub ollama_url: String,
|
pub ollama_url: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub openai_api_key: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub anthropic_api_key: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub fireworks_api_key: Option<String>,
|
pub fireworks_api_key: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub openrouter_api_key: Option<String>,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,9 +39,8 @@ impl Default for AiSettings {
|
|||||||
Self {
|
Self {
|
||||||
provider: AiProvider::Ollama,
|
provider: AiProvider::Ollama,
|
||||||
ollama_url: "http://localhost:11434".to_string(),
|
ollama_url: "http://localhost:11434".to_string(),
|
||||||
openai_api_key: None,
|
|
||||||
anthropic_api_key: None,
|
|
||||||
fireworks_api_key: None,
|
fireworks_api_key: None,
|
||||||
|
openrouter_api_key: None,
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +81,9 @@ pub struct OllamaModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fireworks (OpenAI-compatible chat-completions)
|
// OpenAI-compatible chat-completions (Fireworks, OpenRouter)
|
||||||
|
// These request/response shapes are shared by every OpenAI-compatible provider;
|
||||||
|
// the `Fireworks*` names are retained for historical reasons.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
|||||||
@@ -31,18 +31,3 @@ pub struct ChatTurnResult {
|
|||||||
pub messages: Vec<ChatMessage>,
|
pub messages: Vec<ChatMessage>,
|
||||||
pub usage: ContextUsage,
|
pub usage: ContextUsage,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Chart configuration produced by the agent's `make_chart` tool.
|
|
||||||
/// Embedded as JSON in `ToolResult.text` for tool == "make_chart" while the
|
|
||||||
/// underlying data lives in `ToolResult.result`. The frontend reads both to
|
|
||||||
/// render the chart inline.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ChartConfig {
|
|
||||||
pub chart_type: String, // "bar" | "line" | "area" | "pie"
|
|
||||||
pub x: String, // column name for X axis / category
|
|
||||||
pub y: String, // column name for Y axis / numeric value
|
|
||||||
pub group: Option<String>, // optional column for series grouping
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub orientation: Option<String>, // "vertical" | "horizontal" — bar only
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Instant;
|
||||||
use tokio::sync::{watch, RwLock};
|
use tokio::sync::{watch, RwLock};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -17,12 +17,6 @@ pub enum DbFlavor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SchemaCacheEntry {
|
|
||||||
pub schema_text: String,
|
|
||||||
pub cached_at: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CachedString {
|
pub struct CachedString {
|
||||||
pub value: String,
|
pub value: String,
|
||||||
@@ -40,23 +34,19 @@ pub struct AppState {
|
|||||||
pub ch_clients: RwLock<HashMap<String, Arc<ChClient>>>,
|
pub ch_clients: RwLock<HashMap<String, Arc<ChClient>>>,
|
||||||
pub read_only: RwLock<HashMap<String, bool>>,
|
pub read_only: RwLock<HashMap<String, bool>>,
|
||||||
pub db_flavors: RwLock<HashMap<String, DbFlavor>>,
|
pub db_flavors: RwLock<HashMap<String, DbFlavor>>,
|
||||||
/// Legacy cache used by generate_sql/explain_sql/fix_sql_error — full DDL.
|
/// Greenplum major version (6 or 7), tracked separately because GP6 and GP7
|
||||||
pub schema_cache: RwLock<HashMap<String, SchemaCacheEntry>>,
|
/// expose very different system catalogs (GP6 = PG9.4 base, GP7 = PG14 base).
|
||||||
/// Chat v2 caches: lite overview per connection.
|
pub gp_majors: RwLock<HashMap<String, u8>>,
|
||||||
|
/// Chat agent caches: lite overview per connection.
|
||||||
pub overview_cache: RwLock<HashMap<String, CachedString>>,
|
pub overview_cache: RwLock<HashMap<String, CachedString>>,
|
||||||
/// Chat v2 caches: list of tables per (connection_id, db_name) — used for
|
/// Chat agent caches: list of tables per (connection_id, db_name) — used for
|
||||||
/// list_tables on a non-active PG database via temporary pool.
|
/// list_tables on a non-active PG database via temporary pool.
|
||||||
pub tables_by_db_cache: RwLock<HashMap<(String, String), CachedVec<String>>>,
|
pub tables_by_db_cache: RwLock<HashMap<(String, String), CachedVec<String>>>,
|
||||||
/// Chat v2 caches: column block per (connection_id, db_name, "schema.table").
|
|
||||||
pub columns_cache: RwLock<HashMap<(String, String, String), CachedString>>,
|
|
||||||
pub mcp_shutdown_tx: watch::Sender<bool>,
|
pub mcp_shutdown_tx: watch::Sender<bool>,
|
||||||
pub mcp_running: RwLock<bool>,
|
pub mcp_running: RwLock<bool>,
|
||||||
pub ai_settings: RwLock<Option<AiSettings>>,
|
pub ai_settings: RwLock<Option<AiSettings>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCHEMA_CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes
|
|
||||||
const SCHEMA_CACHE_MAX_SIZE: usize = 100;
|
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let (mcp_shutdown_tx, _) = watch::channel(false);
|
let (mcp_shutdown_tx, _) = watch::channel(false);
|
||||||
@@ -65,10 +55,9 @@ impl AppState {
|
|||||||
ch_clients: RwLock::new(HashMap::new()),
|
ch_clients: RwLock::new(HashMap::new()),
|
||||||
read_only: RwLock::new(HashMap::new()),
|
read_only: RwLock::new(HashMap::new()),
|
||||||
db_flavors: RwLock::new(HashMap::new()),
|
db_flavors: RwLock::new(HashMap::new()),
|
||||||
schema_cache: RwLock::new(HashMap::new()),
|
gp_majors: RwLock::new(HashMap::new()),
|
||||||
overview_cache: RwLock::new(HashMap::new()),
|
overview_cache: RwLock::new(HashMap::new()),
|
||||||
tables_by_db_cache: RwLock::new(HashMap::new()),
|
tables_by_db_cache: RwLock::new(HashMap::new()),
|
||||||
columns_cache: RwLock::new(HashMap::new()),
|
|
||||||
mcp_shutdown_tx,
|
mcp_shutdown_tx,
|
||||||
mcp_running: RwLock::new(false),
|
mcp_running: RwLock::new(false),
|
||||||
ai_settings: RwLock::new(None),
|
ai_settings: RwLock::new(None),
|
||||||
@@ -78,16 +67,11 @@ impl AppState {
|
|||||||
/// Drop every chat-agent cache entry tied to this connection.
|
/// Drop every chat-agent cache entry tied to this connection.
|
||||||
/// Called by switch_database_core, disconnect, and on connection delete.
|
/// Called by switch_database_core, disconnect, and on connection delete.
|
||||||
pub async fn invalidate_chat_caches_for(&self, connection_id: &str) {
|
pub async fn invalidate_chat_caches_for(&self, connection_id: &str) {
|
||||||
self.schema_cache.write().await.remove(connection_id);
|
|
||||||
self.overview_cache.write().await.remove(connection_id);
|
self.overview_cache.write().await.remove(connection_id);
|
||||||
self.tables_by_db_cache
|
self.tables_by_db_cache
|
||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
.retain(|(cid, _), _| cid != connection_id);
|
.retain(|(cid, _), _| cid != connection_id);
|
||||||
self.columns_cache
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.retain(|(cid, _, _), _| cid != connection_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_pool(&self, connection_id: &str) -> TuskResult<PgPool> {
|
pub async fn get_pool(&self, connection_id: &str) -> TuskResult<PgPool> {
|
||||||
@@ -116,38 +100,9 @@ impl AppState {
|
|||||||
map.get(id).copied().unwrap_or(DbFlavor::PostgreSQL)
|
map.get(id).copied().unwrap_or(DbFlavor::PostgreSQL)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_schema_cache(&self, connection_id: &str) -> Option<String> {
|
/// Returns the Greenplum major version (6 or 7) for a connection, or None
|
||||||
let cache = self.schema_cache.read().await;
|
/// for non-GP connections / when the version string couldn't be parsed.
|
||||||
cache.get(connection_id).and_then(|entry| {
|
pub async fn get_gp_major(&self, id: &str) -> Option<u8> {
|
||||||
if entry.cached_at.elapsed() < SCHEMA_CACHE_TTL {
|
self.gp_majors.read().await.get(id).copied()
|
||||||
Some(entry.schema_text.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_schema_cache(&self, connection_id: String, schema_text: String) {
|
|
||||||
let mut cache = self.schema_cache.write().await;
|
|
||||||
// Evict stale entries to prevent unbounded memory growth
|
|
||||||
cache.retain(|_, entry| entry.cached_at.elapsed() < SCHEMA_CACHE_TTL);
|
|
||||||
// If still at capacity, remove the oldest entry
|
|
||||||
if cache.len() >= SCHEMA_CACHE_MAX_SIZE {
|
|
||||||
if let Some(oldest_key) = cache
|
|
||||||
.iter()
|
|
||||||
.min_by_key(|(_, e)| e.cached_at)
|
|
||||||
.map(|(k, _)| k.clone())
|
|
||||||
{
|
|
||||||
cache.remove(&oldest_key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cache.insert(
|
|
||||||
connection_id,
|
|
||||||
SchemaCacheEntry {
|
|
||||||
schema_text,
|
|
||||||
cached_at: Instant::now(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { AiSettingsPopover } from "./AiSettingsPopover";
|
|
||||||
import { useGenerateSql } from "@/hooks/use-ai";
|
|
||||||
import { Sparkles, Loader2, X, Eraser } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
connectionId: string;
|
|
||||||
onSqlGenerated: (sql: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onExecute?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AiBar({ connectionId, onSqlGenerated, onClose, onExecute }: Props) {
|
|
||||||
const [prompt, setPrompt] = useState("");
|
|
||||||
const generateMutation = useGenerateSql();
|
|
||||||
|
|
||||||
const handleGenerate = () => {
|
|
||||||
if (!prompt.trim() || generateMutation.isPending) return;
|
|
||||||
generateMutation.mutate(
|
|
||||||
{ connectionId, prompt },
|
|
||||||
{
|
|
||||||
onSuccess: (sql) => {
|
|
||||||
onSqlGenerated(sql);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error("AI generation failed", { description: String(err) });
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onExecute?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleGenerate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.stopPropagation();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tusk-ai-bar flex items-center gap-2 px-2 py-1.5 tusk-fade-in">
|
|
||||||
<Sparkles className="h-3.5 w-3.5 shrink-0 tusk-ai-icon" />
|
|
||||||
<Input
|
|
||||||
value={prompt}
|
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Describe the query you want..."
|
|
||||||
className="h-7 min-w-0 flex-1 border-tusk-purple/20 bg-tusk-purple/5 text-xs placeholder:text-muted-foreground/40 focus:border-tusk-purple/40 focus:ring-tusk-purple/20"
|
|
||||||
autoFocus
|
|
||||||
disabled={generateMutation.isPending}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
className="gap-1 text-[11px] text-tusk-purple hover:bg-tusk-purple/10 hover:text-tusk-purple"
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={generateMutation.isPending || !prompt.trim()}
|
|
||||||
>
|
|
||||||
{generateMutation.isPending ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Generate"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{prompt.trim() && (
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setPrompt("")}
|
|
||||||
title="Clear prompt"
|
|
||||||
disabled={generateMutation.isPending}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<Eraser className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<AiSettingsPopover />
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onClose}
|
|
||||||
title="Close AI bar"
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useFireworksModels, useOllamaModels } from "@/hooks/use-ai";
|
import {
|
||||||
|
useFireworksModels,
|
||||||
|
useOllamaModels,
|
||||||
|
useOpenRouterModels,
|
||||||
|
} from "@/hooks/use-ai";
|
||||||
import { RefreshCw, Loader2 } from "lucide-react";
|
import { RefreshCw, Loader2 } from "lucide-react";
|
||||||
import type { AiProvider, OllamaModel } from "@/types";
|
import type { AiProvider, OllamaModel } from "@/types";
|
||||||
|
|
||||||
@@ -17,6 +21,8 @@ interface Props {
|
|||||||
onOllamaUrlChange: (url: string) => void;
|
onOllamaUrlChange: (url: string) => void;
|
||||||
fireworksApiKey: string;
|
fireworksApiKey: string;
|
||||||
onFireworksApiKeyChange: (key: string) => void;
|
onFireworksApiKeyChange: (key: string) => void;
|
||||||
|
openrouterApiKey: string;
|
||||||
|
onOpenRouterApiKeyChange: (key: string) => void;
|
||||||
model: string;
|
model: string;
|
||||||
onModelChange: (model: string) => void;
|
onModelChange: (model: string) => void;
|
||||||
}
|
}
|
||||||
@@ -27,6 +33,8 @@ export function AiSettingsFields({
|
|||||||
onOllamaUrlChange,
|
onOllamaUrlChange,
|
||||||
fireworksApiKey,
|
fireworksApiKey,
|
||||||
onFireworksApiKeyChange,
|
onFireworksApiKeyChange,
|
||||||
|
openrouterApiKey,
|
||||||
|
onOpenRouterApiKeyChange,
|
||||||
model,
|
model,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -41,6 +49,17 @@ export function AiSettingsFields({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === "openrouter") {
|
||||||
|
return (
|
||||||
|
<OpenRouterFields
|
||||||
|
apiKey={openrouterApiKey}
|
||||||
|
onApiKeyChange={onOpenRouterApiKeyChange}
|
||||||
|
model={model}
|
||||||
|
onModelChange={onModelChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OllamaFields
|
<OllamaFields
|
||||||
ollamaUrl={ollamaUrl}
|
ollamaUrl={ollamaUrl}
|
||||||
@@ -143,6 +162,55 @@ function FireworksFields({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OpenRouterFields({
|
||||||
|
apiKey,
|
||||||
|
onApiKeyChange,
|
||||||
|
model,
|
||||||
|
onModelChange,
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
onApiKeyChange: (key: string) => void;
|
||||||
|
model: string;
|
||||||
|
onModelChange: (model: string) => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
data: models,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = useOpenRouterModels(apiKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs text-muted-foreground">OpenRouter API key</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => onApiKeyChange(e.target.value)}
|
||||||
|
placeholder="sk-or-..."
|
||||||
|
className="h-8 text-xs"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground/70">
|
||||||
|
Stored locally; sent only to openrouter.ai.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModelDropdown
|
||||||
|
models={models}
|
||||||
|
loading={isLoading}
|
||||||
|
errored={isError}
|
||||||
|
errorText="Cannot reach OpenRouter (check API key)"
|
||||||
|
onRefresh={() => refetch()}
|
||||||
|
model={model}
|
||||||
|
onModelChange={onModelChange}
|
||||||
|
emptyHint={apiKey.trim() ? "Click ↻ to load models" : "Enter API key first"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ModelDropdown({
|
function ModelDropdown({
|
||||||
models,
|
models,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { AiProvider } from "@/types";
|
|||||||
const SUPPORTED_PROVIDERS: { value: AiProvider; label: string }[] = [
|
const SUPPORTED_PROVIDERS: { value: AiProvider; label: string }[] = [
|
||||||
{ value: "ollama", label: "Ollama (local)" },
|
{ value: "ollama", label: "Ollama (local)" },
|
||||||
{ value: "fireworks", label: "Fireworks AI" },
|
{ value: "fireworks", label: "Fireworks AI" },
|
||||||
|
{ value: "openrouter", label: "OpenRouter" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AiSettingsPopover() {
|
export function AiSettingsPopover() {
|
||||||
@@ -30,22 +31,16 @@ export function AiSettingsPopover() {
|
|||||||
const [provider, setProvider] = useState<AiProvider | null>(null);
|
const [provider, setProvider] = useState<AiProvider | null>(null);
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
const [fireworksKey, setFireworksKey] = useState<string | null>(null);
|
const [fireworksKey, setFireworksKey] = useState<string | null>(null);
|
||||||
|
const [openrouterKey, setOpenrouterKey] = useState<string | null>(null);
|
||||||
const [model, setModel] = useState<string | null>(null);
|
const [model, setModel] = useState<string | null>(null);
|
||||||
|
|
||||||
const settingsProvider = settings?.provider;
|
|
||||||
// Hide unsupported legacy values (openai/anthropic) from the selector.
|
|
||||||
const normalizedSettingsProvider: AiProvider | undefined =
|
|
||||||
settingsProvider === "ollama" || settingsProvider === "fireworks"
|
|
||||||
? settingsProvider
|
|
||||||
: settingsProvider
|
|
||||||
? "ollama"
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const currentProvider: AiProvider =
|
const currentProvider: AiProvider =
|
||||||
provider ?? normalizedSettingsProvider ?? "ollama";
|
provider ?? settings?.provider ?? "ollama";
|
||||||
const currentUrl = url ?? settings?.ollama_url ?? "http://localhost:11434";
|
const currentUrl = url ?? settings?.ollama_url ?? "http://localhost:11434";
|
||||||
const currentFireworksKey =
|
const currentFireworksKey =
|
||||||
fireworksKey ?? settings?.fireworks_api_key ?? "";
|
fireworksKey ?? settings?.fireworks_api_key ?? "";
|
||||||
|
const currentOpenrouterKey =
|
||||||
|
openrouterKey ?? settings?.openrouter_api_key ?? "";
|
||||||
const currentModel = model ?? settings?.model ?? "";
|
const currentModel = model ?? settings?.model ?? "";
|
||||||
|
|
||||||
const handleProviderChange = (next: AiProvider) => {
|
const handleProviderChange = (next: AiProvider) => {
|
||||||
@@ -64,6 +59,10 @@ export function AiSettingsPopover() {
|
|||||||
currentProvider === "fireworks"
|
currentProvider === "fireworks"
|
||||||
? currentFireworksKey.trim() || undefined
|
? currentFireworksKey.trim() || undefined
|
||||||
: settings?.fireworks_api_key,
|
: settings?.fireworks_api_key,
|
||||||
|
openrouter_api_key:
|
||||||
|
currentProvider === "openrouter"
|
||||||
|
? currentOpenrouterKey.trim() || undefined
|
||||||
|
: settings?.openrouter_api_key,
|
||||||
model: currentModel,
|
model: currentModel,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -117,6 +116,8 @@ export function AiSettingsPopover() {
|
|||||||
onOllamaUrlChange={setUrl}
|
onOllamaUrlChange={setUrl}
|
||||||
fireworksApiKey={currentFireworksKey}
|
fireworksApiKey={currentFireworksKey}
|
||||||
onFireworksApiKeyChange={setFireworksKey}
|
onFireworksApiKeyChange={setFireworksKey}
|
||||||
|
openrouterApiKey={currentOpenrouterKey}
|
||||||
|
onOpenRouterApiKeyChange={setOpenrouterKey}
|
||||||
model={currentModel}
|
model={currentModel}
|
||||||
onModelChange={setModel}
|
onModelChange={setModel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,327 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import {
|
|
||||||
Area,
|
|
||||||
AreaChart,
|
|
||||||
Bar,
|
|
||||||
BarChart,
|
|
||||||
CartesianGrid,
|
|
||||||
Cell,
|
|
||||||
Legend,
|
|
||||||
Line,
|
|
||||||
LineChart,
|
|
||||||
Pie,
|
|
||||||
PieChart,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from "recharts";
|
|
||||||
import type { ChartConfig } from "@/types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
config: ChartConfig;
|
|
||||||
columns: string[];
|
|
||||||
rows: unknown[][];
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PALETTE = [
|
|
||||||
"#60a5fa", // blue-400
|
|
||||||
"#34d399", // emerald-400
|
|
||||||
"#fbbf24", // amber-400
|
|
||||||
"#f87171", // red-400
|
|
||||||
"#a78bfa", // violet-400
|
|
||||||
"#22d3ee", // cyan-400
|
|
||||||
"#fb923c", // orange-400
|
|
||||||
"#f472b6", // pink-400
|
|
||||||
];
|
|
||||||
|
|
||||||
const MAX_POINTS = 500;
|
|
||||||
|
|
||||||
export function ChartPreview({ config, columns, rows, height = 280 }: Props) {
|
|
||||||
const xIdx = columns.indexOf(config.x);
|
|
||||||
const yIdx = columns.indexOf(config.y);
|
|
||||||
const groupIdx = config.group ? columns.indexOf(config.group) : -1;
|
|
||||||
|
|
||||||
const limited = useMemo(() => rows.slice(0, MAX_POINTS), [rows]);
|
|
||||||
|
|
||||||
if (xIdx < 0 || yIdx < 0) {
|
|
||||||
return (
|
|
||||||
<ChartFallback
|
|
||||||
config={config}
|
|
||||||
message={`Column not found: ${xIdx < 0 ? config.x : config.y}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Coerce y values to numbers; chart libs need numeric Y.
|
|
||||||
const numericY = (v: unknown): number => {
|
|
||||||
if (typeof v === "number") return v;
|
|
||||||
if (typeof v === "string") {
|
|
||||||
const n = parseFloat(v);
|
|
||||||
return Number.isFinite(n) ? n : 0;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelX = (v: unknown): string => {
|
|
||||||
if (v == null) return "—";
|
|
||||||
if (typeof v === "string") return v;
|
|
||||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
||||||
return JSON.stringify(v);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isGrouped = groupIdx >= 0;
|
|
||||||
|
|
||||||
// ──────────── grouped data shape ────────────
|
|
||||||
// For multi-series: pivot to { x: <xValue>, <group1>: yVal, <group2>: yVal, … }
|
|
||||||
// Used by line, area, and grouped-bar.
|
|
||||||
const pivoted = useMemo(() => {
|
|
||||||
if (!isGrouped) return null;
|
|
||||||
const map = new Map<string, Record<string, unknown>>();
|
|
||||||
const groupSet = new Set<string>();
|
|
||||||
for (const row of limited) {
|
|
||||||
const xv = labelX(row[xIdx]);
|
|
||||||
const gv = labelX(row[groupIdx!]);
|
|
||||||
const yv = numericY(row[yIdx]);
|
|
||||||
groupSet.add(gv);
|
|
||||||
const acc = map.get(xv) ?? { _x: xv };
|
|
||||||
acc[gv] = ((acc[gv] as number) ?? 0) + yv;
|
|
||||||
map.set(xv, acc);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
data: Array.from(map.values()),
|
|
||||||
groups: Array.from(groupSet),
|
|
||||||
};
|
|
||||||
}, [isGrouped, limited, xIdx, yIdx, groupIdx]);
|
|
||||||
|
|
||||||
// Single series shape: [{ _x, _y }]
|
|
||||||
const flat = useMemo(() => {
|
|
||||||
return limited.map((row) => ({
|
|
||||||
_x: labelX(row[xIdx]),
|
|
||||||
_y: numericY(row[yIdx]),
|
|
||||||
}));
|
|
||||||
}, [limited, xIdx, yIdx]);
|
|
||||||
|
|
||||||
const tickStyle = {
|
|
||||||
fill: "var(--muted-foreground)",
|
|
||||||
fontSize: 10,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const axisLine = {
|
|
||||||
stroke: "rgba(255, 255, 255, 0.08)",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const tooltipStyle = {
|
|
||||||
backgroundColor: "var(--popover)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 11,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
if (config.chart_type === "pie") {
|
|
||||||
// Pie: aggregate y by x label (sum), no group support.
|
|
||||||
const agg = new Map<string, number>();
|
|
||||||
for (const row of limited) {
|
|
||||||
const xv = labelX(row[xIdx]);
|
|
||||||
agg.set(xv, (agg.get(xv) ?? 0) + numericY(row[yIdx]));
|
|
||||||
}
|
|
||||||
const data = Array.from(agg.entries()).map(([name, value]) => ({ name, value }));
|
|
||||||
return (
|
|
||||||
<ChartFrame config={config} height={height} count={data.length} totalRows={rows.length}>
|
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={data}
|
|
||||||
dataKey="value"
|
|
||||||
nameKey="name"
|
|
||||||
outerRadius={Math.min(height / 2.5, 110)}
|
|
||||||
label={(entry) =>
|
|
||||||
typeof entry.name === "string" && entry.name.length < 20 ? entry.name : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{data.map((_, i) => (
|
|
||||||
<Cell key={i} fill={PALETTE[i % PALETTE.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip contentStyle={tooltipStyle} />
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: 11, color: "var(--muted-foreground)" }}
|
|
||||||
verticalAlign="bottom"
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</ChartFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.chart_type === "line") {
|
|
||||||
return (
|
|
||||||
<ChartFrame
|
|
||||||
config={config}
|
|
||||||
height={height}
|
|
||||||
count={isGrouped ? pivoted!.data.length : flat.length}
|
|
||||||
totalRows={rows.length}
|
|
||||||
>
|
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
|
||||||
<LineChart data={isGrouped ? pivoted!.data : flat} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
|
|
||||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
|
|
||||||
<XAxis dataKey="_x" tick={tickStyle} axisLine={axisLine} tickLine={axisLine} />
|
|
||||||
<YAxis tick={tickStyle} axisLine={axisLine} tickLine={axisLine} />
|
|
||||||
<Tooltip contentStyle={tooltipStyle} />
|
|
||||||
{isGrouped ? (
|
|
||||||
<>
|
|
||||||
<Legend wrapperStyle={{ fontSize: 11, color: "var(--muted-foreground)" }} />
|
|
||||||
{pivoted!.groups.map((g, i) => (
|
|
||||||
<Line
|
|
||||||
key={g}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={g}
|
|
||||||
stroke={PALETTE[i % PALETTE.length]}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Line type="monotone" dataKey="_y" stroke={PALETTE[0]} strokeWidth={2} dot={false} />
|
|
||||||
)}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</ChartFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.chart_type === "area") {
|
|
||||||
return (
|
|
||||||
<ChartFrame
|
|
||||||
config={config}
|
|
||||||
height={height}
|
|
||||||
count={isGrouped ? pivoted!.data.length : flat.length}
|
|
||||||
totalRows={rows.length}
|
|
||||||
>
|
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
|
||||||
<AreaChart data={isGrouped ? pivoted!.data : flat} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
|
|
||||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
|
|
||||||
<XAxis dataKey="_x" tick={tickStyle} axisLine={axisLine} tickLine={axisLine} />
|
|
||||||
<YAxis tick={tickStyle} axisLine={axisLine} tickLine={axisLine} />
|
|
||||||
<Tooltip contentStyle={tooltipStyle} />
|
|
||||||
{isGrouped ? (
|
|
||||||
<>
|
|
||||||
<Legend wrapperStyle={{ fontSize: 11, color: "var(--muted-foreground)" }} />
|
|
||||||
{pivoted!.groups.map((g, i) => (
|
|
||||||
<Area
|
|
||||||
key={g}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={g}
|
|
||||||
stackId="1"
|
|
||||||
stroke={PALETTE[i % PALETTE.length]}
|
|
||||||
fill={PALETTE[i % PALETTE.length]}
|
|
||||||
fillOpacity={0.35}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="_y"
|
|
||||||
stroke={PALETTE[0]}
|
|
||||||
fill={PALETTE[0]}
|
|
||||||
fillOpacity={0.35}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</ChartFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// bar (default)
|
|
||||||
const horizontal = config.orientation === "horizontal";
|
|
||||||
return (
|
|
||||||
<ChartFrame
|
|
||||||
config={config}
|
|
||||||
height={height}
|
|
||||||
count={isGrouped ? pivoted!.data.length : flat.length}
|
|
||||||
totalRows={rows.length}
|
|
||||||
>
|
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
|
||||||
<BarChart
|
|
||||||
layout={horizontal ? "vertical" : "horizontal"}
|
|
||||||
data={isGrouped ? pivoted!.data : flat}
|
|
||||||
margin={{ top: 8, right: 12, left: horizontal ? 24 : 0, bottom: 4 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={horizontal} horizontal={!horizontal} />
|
|
||||||
{horizontal ? (
|
|
||||||
<>
|
|
||||||
<XAxis type="number" tick={tickStyle} axisLine={axisLine} tickLine={axisLine} />
|
|
||||||
<YAxis dataKey="_x" type="category" tick={tickStyle} axisLine={axisLine} tickLine={axisLine} width={100} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<XAxis dataKey="_x" tick={tickStyle} axisLine={axisLine} tickLine={axisLine} />
|
|
||||||
<YAxis tick={tickStyle} axisLine={axisLine} tickLine={axisLine} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Tooltip contentStyle={tooltipStyle} />
|
|
||||||
{isGrouped ? (
|
|
||||||
<>
|
|
||||||
<Legend wrapperStyle={{ fontSize: 11, color: "var(--muted-foreground)" }} />
|
|
||||||
{pivoted!.groups.map((g, i) => (
|
|
||||||
<Bar key={g} dataKey={g} fill={PALETTE[i % PALETTE.length]} radius={[3, 3, 0, 0]} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Bar dataKey="_y" fill={PALETTE[0]} radius={[3, 3, 0, 0]} />
|
|
||||||
)}
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</ChartFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChartFrame({
|
|
||||||
config,
|
|
||||||
height,
|
|
||||||
count,
|
|
||||||
totalRows,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
config: ChartConfig;
|
|
||||||
height: number;
|
|
||||||
count: number;
|
|
||||||
totalRows: number;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-border/40 bg-background">
|
|
||||||
<div className="flex items-center gap-2 border-b border-border/30 px-2 py-1 text-[11px] text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground/80">
|
|
||||||
{config.title ?? `${capitalize(config.chart_type)} chart`}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto text-muted-foreground/60">
|
|
||||||
{count} point{count === 1 ? "" : "s"}
|
|
||||||
{totalRows > MAX_POINTS && ` (of ${totalRows}, capped at ${MAX_POINTS})`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-2" style={{ minHeight: height }}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChartFallback({ config, message }: { config: ChartConfig; message: string }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-xs">
|
|
||||||
<div className="font-medium text-destructive">
|
|
||||||
Chart {config.chart_type} failed
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-muted-foreground">{message}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function capitalize(s: string) {
|
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ResultsTable } from "@/components/results/ResultsTable";
|
import { ResultsTable } from "@/components/results/ResultsTable";
|
||||||
import { ExportDialog } from "@/components/export/ExportDialog";
|
import { ExportDialog } from "@/components/export/ExportDialog";
|
||||||
import { ChartPreview } from "./ChartPreview";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -15,19 +14,12 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
User,
|
User,
|
||||||
Wrench,
|
|
||||||
Database,
|
Database,
|
||||||
Columns,
|
|
||||||
Layers,
|
|
||||||
RefreshCw,
|
|
||||||
StickyNote,
|
|
||||||
Bookmark,
|
|
||||||
BookmarkPlus,
|
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Download,
|
Download,
|
||||||
BarChart3,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ChartConfig, ChatMessage } from "@/types";
|
import type { ChatMessage } from "@/types";
|
||||||
|
import { getToolMeta, isQueryResultTool } from "./tool-registry";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
@@ -79,8 +71,10 @@ function AssistantBubble({ text }: { text: string }) {
|
|||||||
|
|
||||||
function ToolCallBlock({ tool, inputJson }: { tool: string; inputJson: string }) {
|
function ToolCallBlock({ tool, inputJson }: { tool: string; inputJson: string }) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const preview = extractToolPreview(tool, inputJson);
|
const meta = getToolMeta(tool);
|
||||||
const Icon = iconForTool(tool);
|
const preview = previewFromJson(tool, inputJson);
|
||||||
|
const Icon = meta.icon;
|
||||||
|
const showSqlPreview = (tool === "run_query" || tool === "explain_query") && preview;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ml-8 rounded-md border border-border/40 bg-muted/20">
|
<div className="ml-8 rounded-md border border-border/40 bg-muted/20">
|
||||||
@@ -91,17 +85,14 @@ function ToolCallBlock({ tool, inputJson }: { tool: string; inputJson: string })
|
|||||||
>
|
>
|
||||||
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||||
<Icon className="h-3 w-3" />
|
<Icon className="h-3 w-3" />
|
||||||
<span className="font-medium">{labelForTool(tool)}</span>
|
<span className="font-medium">{meta.label}</span>
|
||||||
{preview && (
|
{preview && (
|
||||||
<span className="ml-1 truncate text-muted-foreground/70">
|
<span className="ml-1 truncate text-muted-foreground/70">{preview}</span>
|
||||||
{preview.slice(0, 80)}
|
|
||||||
{preview.length > 80 ? "…" : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="border-t border-border/30 p-2">
|
<div className="border-t border-border/30 p-2">
|
||||||
{tool === "run_query" && preview ? (
|
{showSqlPreview ? (
|
||||||
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-background/60 p-2 font-mono text-[11px]">
|
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-background/60 p-2 font-mono text-[11px]">
|
||||||
{preview}
|
{preview}
|
||||||
</pre>
|
</pre>
|
||||||
@@ -116,6 +107,15 @@ function ToolCallBlock({ tool, inputJson }: { tool: string; inputJson: string })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function previewFromJson(tool: string, inputJson: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(inputJson) as Record<string, unknown>;
|
||||||
|
return getToolMeta(tool).preview(parsed);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ToolResultBlock({
|
function ToolResultBlock({
|
||||||
tool,
|
tool,
|
||||||
isError,
|
isError,
|
||||||
@@ -132,87 +132,20 @@ function ToolResultBlock({
|
|||||||
<div className="ml-8 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
|
<div className="ml-8 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
|
||||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
|
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-destructive">{labelForTool(tool)} failed</div>
|
<div className="font-medium text-destructive">{getToolMeta(tool).label} failed</div>
|
||||||
{text && <div className="mt-1 whitespace-pre-wrap text-muted-foreground">{text}</div>}
|
{text && <div className="mt-1 whitespace-pre-wrap text-muted-foreground">{text}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy schema tool — keep a one-line indicator for old threads.
|
// Tools that produce a QueryResult (rendered as a table): run_query, sample_data.
|
||||||
if (tool === "get_schema") {
|
if (isQueryResultTool(tool) && result) {
|
||||||
return (
|
|
||||||
<div className="ml-8 flex items-center gap-2 rounded-md border border-border/40 bg-muted/20 px-2 py-1.5 text-xs text-muted-foreground">
|
|
||||||
<Database className="h-3 w-3" />
|
|
||||||
<span>Loaded schema context ({text?.length ?? 0} chars)</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text-only tools (chat v2/v3): list_databases, list_tables, get_columns, switch_database,
|
|
||||||
// remember, save_query, find_queries.
|
|
||||||
if (
|
|
||||||
tool === "list_databases" ||
|
|
||||||
tool === "list_tables" ||
|
|
||||||
tool === "get_columns" ||
|
|
||||||
tool === "switch_database" ||
|
|
||||||
tool === "remember" ||
|
|
||||||
tool === "save_query" ||
|
|
||||||
tool === "find_queries"
|
|
||||||
) {
|
|
||||||
return <TextToolResult tool={tool} text={text} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// make_chart — render chart inline using config from text + data from result.
|
|
||||||
if (tool === "make_chart") {
|
|
||||||
return <ChartToolResult text={text} result={result} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// run_query — full results table with Open-full / Export actions.
|
|
||||||
if (result) {
|
|
||||||
return <RunQueryResultBlock result={result} />;
|
return <RunQueryResultBlock result={result} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
// Everything else falls back to a collapsible text block.
|
||||||
}
|
return <TextToolResult tool={tool} text={text} />;
|
||||||
|
|
||||||
function ChartToolResult({
|
|
||||||
text,
|
|
||||||
result,
|
|
||||||
}: {
|
|
||||||
text: string | null;
|
|
||||||
result: { columns: string[]; types: string[]; rows: unknown[][]; row_count: number; execution_time_ms: number } | null;
|
|
||||||
}) {
|
|
||||||
let config: ChartConfig | null = null;
|
|
||||||
try {
|
|
||||||
if (text) {
|
|
||||||
config = JSON.parse(text) as ChartConfig;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
config = null;
|
|
||||||
}
|
|
||||||
if (!config || !result) {
|
|
||||||
return (
|
|
||||||
<div className="ml-8 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
|
|
||||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-destructive">Chart unavailable</div>
|
|
||||||
<div className="mt-1 text-muted-foreground">
|
|
||||||
The agent referenced a chart but the previous query result is not attached.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="ml-8">
|
|
||||||
<ChartPreview
|
|
||||||
config={config}
|
|
||||||
columns={result.columns}
|
|
||||||
rows={result.rows}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function RunQueryResultBlock({
|
function RunQueryResultBlock({
|
||||||
@@ -315,8 +248,10 @@ function RunQueryResultBlock({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TextToolResult({ tool, text }: { tool: string; text: string | null }) {
|
function TextToolResult({ tool, text }: { tool: string; text: string | null }) {
|
||||||
|
// Lazy preview: switch_database is short; everything else collapses by default.
|
||||||
const [expanded, setExpanded] = useState(tool === "switch_database");
|
const [expanded, setExpanded] = useState(tool === "switch_database");
|
||||||
const Icon = iconForTool(tool);
|
const meta = getToolMeta(tool);
|
||||||
|
const Icon = meta.icon;
|
||||||
const lineCount = text ? text.split("\n").length : 0;
|
const lineCount = text ? text.split("\n").length : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -328,7 +263,7 @@ function TextToolResult({ tool, text }: { tool: string; text: string | null }) {
|
|||||||
>
|
>
|
||||||
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||||
<Icon className="h-3 w-3" />
|
<Icon className="h-3 w-3" />
|
||||||
<span className="font-medium">{labelForTool(tool)}</span>
|
<span className="font-medium">{meta.label}</span>
|
||||||
{text && (
|
{text && (
|
||||||
<span className="ml-1 text-muted-foreground/60">
|
<span className="ml-1 text-muted-foreground/60">
|
||||||
{lineCount} line{lineCount === 1 ? "" : "s"}
|
{lineCount} line{lineCount === 1 ? "" : "s"}
|
||||||
@@ -346,93 +281,6 @@ function TextToolResult({ tool, text }: { tool: string; text: string | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function labelForTool(tool: string): string {
|
|
||||||
switch (tool) {
|
|
||||||
case "run_query":
|
|
||||||
return "Run SQL";
|
|
||||||
case "list_databases":
|
|
||||||
return "List databases";
|
|
||||||
case "list_tables":
|
|
||||||
return "List tables";
|
|
||||||
case "get_columns":
|
|
||||||
return "Inspect columns";
|
|
||||||
case "switch_database":
|
|
||||||
return "Switch database";
|
|
||||||
case "remember":
|
|
||||||
return "Remember";
|
|
||||||
case "save_query":
|
|
||||||
return "Save query";
|
|
||||||
case "find_queries":
|
|
||||||
return "Find saved queries";
|
|
||||||
case "make_chart":
|
|
||||||
return "Make chart";
|
|
||||||
case "get_schema":
|
|
||||||
return "Load schema";
|
|
||||||
default:
|
|
||||||
return tool;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function iconForTool(tool: string) {
|
|
||||||
switch (tool) {
|
|
||||||
case "run_query":
|
|
||||||
return Wrench;
|
|
||||||
case "list_databases":
|
|
||||||
return Database;
|
|
||||||
case "list_tables":
|
|
||||||
return Layers;
|
|
||||||
case "get_columns":
|
|
||||||
return Columns;
|
|
||||||
case "switch_database":
|
|
||||||
return RefreshCw;
|
|
||||||
case "remember":
|
|
||||||
return StickyNote;
|
|
||||||
case "save_query":
|
|
||||||
return BookmarkPlus;
|
|
||||||
case "find_queries":
|
|
||||||
return Bookmark;
|
|
||||||
case "make_chart":
|
|
||||||
return BarChart3;
|
|
||||||
case "get_schema":
|
|
||||||
return Database;
|
|
||||||
default:
|
|
||||||
return Wrench;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractToolPreview(tool: string, inputJson: string): string | null {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(inputJson) as Record<string, unknown>;
|
|
||||||
switch (tool) {
|
|
||||||
case "run_query":
|
|
||||||
return typeof parsed.sql === "string" ? parsed.sql : null;
|
|
||||||
case "list_tables":
|
|
||||||
return typeof parsed.database === "string" ? parsed.database : null;
|
|
||||||
case "switch_database":
|
|
||||||
return typeof parsed.database === "string" ? parsed.database : null;
|
|
||||||
case "get_columns":
|
|
||||||
return Array.isArray(parsed.tables) ? parsed.tables.join(", ") : null;
|
|
||||||
case "remember":
|
|
||||||
return typeof parsed.note === "string" ? parsed.note : null;
|
|
||||||
case "save_query":
|
|
||||||
return typeof parsed.name === "string" ? parsed.name : null;
|
|
||||||
case "find_queries":
|
|
||||||
return typeof parsed.text === "string" ? parsed.text : null;
|
|
||||||
case "make_chart": {
|
|
||||||
const t = typeof parsed.chart_type === "string" ? parsed.chart_type : null;
|
|
||||||
const x = typeof parsed.x === "string" ? parsed.x : null;
|
|
||||||
const y = typeof parsed.y === "string" ? parsed.y : null;
|
|
||||||
if (t && x && y) return `${t}: ${x} → ${y}`;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prettyJson(s: string): string {
|
function prettyJson(s: string): string {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(JSON.parse(s), null, 2);
|
return JSON.stringify(JSON.parse(s), null, 2);
|
||||||
|
|||||||
@@ -111,13 +111,13 @@ function UsageBadge({ usage }: { usage: ContextUsage | undefined }) {
|
|||||||
|
|
||||||
let toneClass = "text-muted-foreground/70";
|
let toneClass = "text-muted-foreground/70";
|
||||||
if (ratio >= 0.85) toneClass = "text-destructive";
|
if (ratio >= 0.85) toneClass = "text-destructive";
|
||||||
else if (ratio >= 0.6) toneClass = "text-amber-500";
|
else if (ratio >= 0.6) toneClass = "text-warning";
|
||||||
else if (ratio >= 0.3) toneClass = "text-emerald-500/80";
|
else if (ratio >= 0.3) toneClass = "text-success/80";
|
||||||
|
|
||||||
const trackClass = "h-1.5 w-12 overflow-hidden rounded-full bg-muted";
|
const trackClass = "h-1.5 w-12 overflow-hidden rounded-full bg-muted";
|
||||||
let fillClass = "bg-emerald-500/70";
|
let fillClass = "bg-success/70";
|
||||||
if (ratio >= 0.85) fillClass = "bg-destructive";
|
if (ratio >= 0.85) fillClass = "bg-destructive";
|
||||||
else if (ratio >= 0.6) fillClass = "bg-amber-500";
|
else if (ratio >= 0.6) fillClass = "bg-warning";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
107
src/components/chat/tool-registry.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
Database,
|
||||||
|
Layers,
|
||||||
|
Columns,
|
||||||
|
RefreshCw,
|
||||||
|
Wrench,
|
||||||
|
StickyNote,
|
||||||
|
Bookmark,
|
||||||
|
BookmarkPlus,
|
||||||
|
Activity,
|
||||||
|
Shuffle,
|
||||||
|
GitBranch,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export type ToolMeta = {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
preview: (parsed: Record<string, unknown>) => string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncate = (s: unknown, n = 80): string | null => {
|
||||||
|
if (typeof s !== "string") return null;
|
||||||
|
return s.length > n ? `${s.slice(0, n)}…` : s;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TOOLS: Record<string, ToolMeta> = {
|
||||||
|
list_databases: {
|
||||||
|
icon: Database,
|
||||||
|
label: "List databases",
|
||||||
|
preview: () => null,
|
||||||
|
},
|
||||||
|
list_tables: {
|
||||||
|
icon: Layers,
|
||||||
|
label: "List tables",
|
||||||
|
preview: (p) => (typeof p.database === "string" ? p.database : null),
|
||||||
|
},
|
||||||
|
get_columns: {
|
||||||
|
icon: Columns,
|
||||||
|
label: "Inspect columns",
|
||||||
|
preview: (p) => (Array.isArray(p.tables) ? (p.tables as string[]).join(", ") : null),
|
||||||
|
},
|
||||||
|
switch_database: {
|
||||||
|
icon: RefreshCw,
|
||||||
|
label: "Switch database",
|
||||||
|
preview: (p) => (typeof p.database === "string" ? p.database : null),
|
||||||
|
},
|
||||||
|
run_query: {
|
||||||
|
icon: Wrench,
|
||||||
|
label: "Run SQL",
|
||||||
|
preview: (p) => truncate(p.sql),
|
||||||
|
},
|
||||||
|
remember: {
|
||||||
|
icon: StickyNote,
|
||||||
|
label: "Remember",
|
||||||
|
preview: (p) => (typeof p.note === "string" ? p.note : null),
|
||||||
|
},
|
||||||
|
save_query: {
|
||||||
|
icon: BookmarkPlus,
|
||||||
|
label: "Save query",
|
||||||
|
preview: (p) => (typeof p.name === "string" ? p.name : null),
|
||||||
|
},
|
||||||
|
find_queries: {
|
||||||
|
icon: Bookmark,
|
||||||
|
label: "Find saved queries",
|
||||||
|
preview: (p) => (typeof p.text === "string" ? p.text : null),
|
||||||
|
},
|
||||||
|
profile_table: {
|
||||||
|
icon: Activity,
|
||||||
|
label: "Profile table",
|
||||||
|
preview: (p) => (typeof p.table === "string" ? p.table : null),
|
||||||
|
},
|
||||||
|
sample_data: {
|
||||||
|
icon: Shuffle,
|
||||||
|
label: "Sample rows",
|
||||||
|
preview: (p) => {
|
||||||
|
const t = typeof p.table === "string" ? p.table : "";
|
||||||
|
const limit = typeof p.limit === "number" ? p.limit : 50;
|
||||||
|
return t ? `${t} (${limit})` : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
explain_query: {
|
||||||
|
icon: GitBranch,
|
||||||
|
label: "Explain query",
|
||||||
|
preview: (p) => truncate(p.sql),
|
||||||
|
},
|
||||||
|
detect_skew: {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
label: "Detect skew",
|
||||||
|
preview: (p) => (typeof p.table === "string" ? p.table : null),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getToolMeta(tool: string): ToolMeta {
|
||||||
|
return (
|
||||||
|
TOOLS[tool] ?? {
|
||||||
|
icon: Wrench,
|
||||||
|
label: tool,
|
||||||
|
preview: () => null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isQueryResultTool(tool: string): boolean {
|
||||||
|
return tool === "run_query" || tool === "sample_data";
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { sql, PostgreSQL, StandardSQL } from "@codemirror/lang-sql";
|
|||||||
import { keymap } from "@codemirror/view";
|
import { keymap } from "@codemirror/view";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
|
import { tuskEditorExtensions } from "@/lib/editor-theme";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -44,6 +45,7 @@ export function SqlEditor({ value, onChange, onExecute, onFormat, schema }: Prop
|
|||||||
const dialect = flavor === "clickhouse" ? StandardSQL : PostgreSQL;
|
const dialect = flavor === "clickhouse" ? StandardSQL : PostgreSQL;
|
||||||
const defaultSchema = flavor === "clickhouse" ? undefined : "public";
|
const defaultSchema = flavor === "clickhouse" ? undefined : "public";
|
||||||
return [
|
return [
|
||||||
|
tuskEditorExtensions,
|
||||||
sql({
|
sql({
|
||||||
dialect,
|
dialect,
|
||||||
schema: sqlNamespace,
|
schema: sqlNamespace,
|
||||||
@@ -80,7 +82,6 @@ export function SqlEditor({ value, onChange, onExecute, onFormat, schema }: Prop
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
theme="dark"
|
|
||||||
// height="100%" propagates down to .cm-editor so the inner .cm-scroller
|
// height="100%" propagates down to .cm-editor so the inner .cm-scroller
|
||||||
// can render a vertical scrollbar; without it, long queries overflow the
|
// can render a vertical scrollbar; without it, long queries overflow the
|
||||||
// flex container and the editor cannot be scrolled.
|
// flex container and the editor cannot be scrolled.
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ export function HistoryPanel() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{entry.status === "success" ? (
|
{entry.status === "success" ? (
|
||||||
<CheckCircle className="h-3 w-3 shrink-0 text-green-500" />
|
<CheckCircle className="h-3 w-3 shrink-0 text-success" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="h-3 w-3 shrink-0 text-red-500" />
|
<XCircle className="h-3 w-3 shrink-0 text-destructive" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate font-mono text-foreground">
|
<span className="truncate font-mono text-foreground">
|
||||||
{entry.sql.length > 80
|
{entry.sql.length > 80
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export function ReadOnlyToggle() {
|
|||||||
size="xs"
|
size="xs"
|
||||||
className={`gap-1.5 font-medium ${
|
className={`gap-1.5 font-medium ${
|
||||||
isReadOnly
|
isReadOnly
|
||||||
? "text-amber-500 hover:bg-amber-500/10 hover:text-amber-500"
|
? "text-warning hover:bg-warning/10 hover:text-warning"
|
||||||
: "text-emerald-500 hover:bg-emerald-500/10 hover:text-emerald-500"
|
: "text-success hover:bg-success/10 hover:text-success"
|
||||||
}`}
|
}`}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={toggleMutation.isPending}
|
disabled={toggleMutation.isPending}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function StatusBar({ rowCount, executionTime }: Props) {
|
|||||||
<span
|
<span
|
||||||
className={`inline-block h-2 w-2 rounded-full ${
|
className={`inline-block h-2 w-2 rounded-full ${
|
||||||
isConnected
|
isConnected
|
||||||
? "bg-emerald-500 shadow-[0_0_6px_theme(--color-emerald-500/40)]"
|
? "bg-success ring-2 ring-success/25"
|
||||||
: "bg-muted-foreground/30"
|
: "bg-muted-foreground/30"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -56,8 +56,8 @@ export function StatusBar({ rowCount, executionTime }: Props) {
|
|||||||
<span
|
<span
|
||||||
className={`rounded px-1 py-px text-[10px] font-semibold tracking-wide ${
|
className={`rounded px-1 py-px text-[10px] font-semibold tracking-wide ${
|
||||||
(readOnlyMap[activeConnectionId] ?? true)
|
(readOnlyMap[activeConnectionId] ?? true)
|
||||||
? "bg-amber-500/10 text-amber-500"
|
? "bg-warning/10 text-warning"
|
||||||
: "bg-emerald-500/10 text-emerald-500"
|
: "bg-success/10 text-success"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{(readOnlyMap[activeConnectionId] ?? true) ? "READ" : "WRITE"}
|
{(readOnlyMap[activeConnectionId] ?? true) ? "READ" : "WRITE"}
|
||||||
@@ -86,7 +86,7 @@ export function StatusBar({ rowCount, executionTime }: Props) {
|
|||||||
<span
|
<span
|
||||||
className={`inline-block h-1.5 w-1.5 rounded-full transition-colors ${
|
className={`inline-block h-1.5 w-1.5 rounded-full transition-colors ${
|
||||||
mcpStatus?.running
|
mcpStatus?.running
|
||||||
? "bg-emerald-500 shadow-[0_0_4px_theme(--color-emerald-500/40)]"
|
? "bg-success ring-2 ring-success/25"
|
||||||
: "bg-muted-foreground/20"
|
: "bg-muted-foreground/20"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ export function Toolbar() {
|
|||||||
"--strip-color": activeColor ?? "transparent",
|
"--strip-color": activeColor ?? "transparent",
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
className="tusk-wordmark select-none px-1 text-[12px] text-primary"
|
||||||
|
style={{ textShadow: "0 0 10px oklch(0.808 0.124 82 / 35%)" }}
|
||||||
|
>
|
||||||
|
TUSK
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="mx-1 h-4 w-px bg-border" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function MemoryPanel() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{dirty && (
|
{dirty && (
|
||||||
<div className="border-t border-border/40 px-3 py-1.5 text-[10px] text-amber-500/80">
|
<div className="border-t border-border/40 px-3 py-1.5 text-[10px] text-warning/80">
|
||||||
Unsaved changes
|
Unsaved changes
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { ChevronRight, ChevronDown } from "lucide-react";
|
|||||||
import type { ExplainNode, ExplainResult } from "@/types";
|
import type { ExplainNode, ExplainResult } from "@/types";
|
||||||
|
|
||||||
function getCostColor(cost: number, maxCost: number): string {
|
function getCostColor(cost: number, maxCost: number): string {
|
||||||
if (maxCost === 0) return "#22c55e";
|
if (maxCost === 0) return "var(--success)";
|
||||||
const ratio = cost / maxCost;
|
const ratio = cost / maxCost;
|
||||||
if (ratio < 0.33) return "#22c55e";
|
if (ratio < 0.33) return "var(--success)";
|
||||||
if (ratio < 0.66) return "#eab308";
|
if (ratio < 0.66) return "var(--warning)";
|
||||||
return "#ef4444";
|
return "var(--destructive)";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMaxCost(node: ExplainNode): number {
|
function getMaxCost(node: ExplainNode): number {
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ function syntaxHighlight(json: string): string {
|
|||||||
return json.replace(
|
return json.replace(
|
||||||
/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
|
/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
|
||||||
(match) => {
|
(match) => {
|
||||||
let cls = "text-blue-500 dark:text-blue-400"; // number
|
let cls = "text-info"; // number
|
||||||
if (match.startsWith('"')) {
|
if (match.startsWith('"')) {
|
||||||
if (match.endsWith(":")) {
|
if (match.endsWith(":")) {
|
||||||
cls = "text-foreground"; // key
|
cls = "text-foreground"; // key
|
||||||
} else {
|
} else {
|
||||||
cls = "text-green-600 dark:text-green-400"; // string
|
cls = "text-success"; // string
|
||||||
}
|
}
|
||||||
} else if (/true|false/.test(match)) {
|
} else if (/true|false/.test(match)) {
|
||||||
cls = "text-purple-500 dark:text-purple-400"; // boolean
|
cls = "text-violet"; // boolean
|
||||||
} else if (match === "null") {
|
} else if (match === "null") {
|
||||||
cls = "text-muted-foreground italic"; // null
|
cls = "text-muted-foreground italic"; // null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { ResultsTable } from "./ResultsTable";
|
import { ResultsTable } from "./ResultsTable";
|
||||||
import { ResultsJsonView } from "./ResultsJsonView";
|
import { ResultsJsonView } from "./ResultsJsonView";
|
||||||
import type { QueryResult } from "@/types";
|
import type { QueryResult } from "@/types";
|
||||||
import { Loader2, AlertCircle, Sparkles, Wand2 } from "lucide-react";
|
import { Loader2, AlertCircle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
result?: QueryResult | null;
|
result?: QueryResult | null;
|
||||||
@@ -15,10 +14,6 @@ interface Props {
|
|||||||
value: unknown
|
value: unknown
|
||||||
) => void;
|
) => void;
|
||||||
highlightedCells?: Set<string>;
|
highlightedCells?: Set<string>;
|
||||||
aiExplanation?: string | null;
|
|
||||||
isAiLoading?: boolean;
|
|
||||||
onExplainError?: () => void;
|
|
||||||
onFixError?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResultsPanel({
|
export function ResultsPanel({
|
||||||
@@ -28,10 +23,6 @@ export function ResultsPanel({
|
|||||||
viewMode = "table",
|
viewMode = "table",
|
||||||
onCellDoubleClick,
|
onCellDoubleClick,
|
||||||
highlightedCells,
|
highlightedCells,
|
||||||
aiExplanation,
|
|
||||||
isAiLoading,
|
|
||||||
onExplainError,
|
|
||||||
onFixError,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -42,22 +33,6 @@ export function ResultsPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aiExplanation) {
|
|
||||||
return (
|
|
||||||
<div className="h-full select-text overflow-auto p-4">
|
|
||||||
<div className="rounded-md border bg-muted/30 p-4">
|
|
||||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
|
||||||
AI Explanation
|
|
||||||
</div>
|
|
||||||
<pre className="whitespace-pre-wrap font-sans text-sm leading-relaxed text-foreground">
|
|
||||||
{aiExplanation}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full select-text flex-col items-center justify-center gap-3 p-4">
|
<div className="flex h-full select-text flex-col items-center justify-center gap-3 p-4">
|
||||||
@@ -65,42 +40,6 @@ export function ResultsPanel({
|
|||||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
<pre className="whitespace-pre-wrap font-mono text-xs">{error}</pre>
|
<pre className="whitespace-pre-wrap font-mono text-xs">{error}</pre>
|
||||||
</div>
|
</div>
|
||||||
{(onExplainError || onFixError) && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{onExplainError && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-7 gap-1.5 text-xs"
|
|
||||||
onClick={onExplainError}
|
|
||||||
disabled={isAiLoading}
|
|
||||||
>
|
|
||||||
{isAiLoading ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Sparkles className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
Explain
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onFixError && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-7 gap-1.5 text-xs"
|
|
||||||
onClick={onFixError}
|
|
||||||
disabled={isAiLoading}
|
|
||||||
>
|
|
||||||
{isAiLoading ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Wand2 className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
Fix with AI
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,9 @@ export function ResultsTable({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.id}
|
key={row.id}
|
||||||
className="tusk-grid-row absolute left-0 flex transition-colors"
|
className={`tusk-grid-row absolute left-0 flex transition-colors ${
|
||||||
|
virtualRow.index % 2 === 1 ? "tusk-grid-row-odd" : ""
|
||||||
|
}`}
|
||||||
style={{
|
style={{
|
||||||
top: `${virtualRow.start}px`,
|
top: `${virtualRow.start}px`,
|
||||||
height: `${virtualRow.size}px`,
|
height: `${virtualRow.size}px`,
|
||||||
@@ -201,7 +203,7 @@ export function ResultsTable({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
className="shrink-0 border-b border-r border-border/20 text-xs"
|
className="shrink-0 border-r border-border/15 text-xs"
|
||||||
style={{ width: w, minWidth: w }}
|
style={{ width: w, minWidth: w }}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
|
|||||||
@@ -1,19 +1,41 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useSavedQueries, useDeleteSavedQuery } from "@/hooks/use-saved-queries";
|
import { useSavedQueries, useDeleteSavedQuery } from "@/hooks/use-saved-queries";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Search, Trash2, Bookmark } from "lucide-react";
|
import { Search, Trash2, Bookmark } from "lucide-react";
|
||||||
import type { Tab } from "@/types";
|
import type { SavedQuery, Tab } from "@/types";
|
||||||
|
|
||||||
|
type Scope = "active" | "all";
|
||||||
|
|
||||||
export function SavedQueriesPanel() {
|
export function SavedQueriesPanel() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const { activeConnectionId, currentDatabase, addTab } = useAppStore();
|
const [scope, setScope] = useState<Scope>("active");
|
||||||
|
|
||||||
|
const { activeConnectionId, currentDatabase, addTab, connections } =
|
||||||
|
useAppStore();
|
||||||
const { data: queries } = useSavedQueries(search || undefined);
|
const { data: queries } = useSavedQueries(search || undefined);
|
||||||
const deleteMutation = useDeleteSavedQuery();
|
const deleteMutation = useDeleteSavedQuery();
|
||||||
|
|
||||||
const handleOpen = (sql: string, connectionId?: string) => {
|
// Effective scope: if no connection is active, "active" has nothing to filter
|
||||||
const cid = activeConnectionId ?? connectionId ?? "";
|
// against, so we silently broaden to all.
|
||||||
|
const effectiveScope: Scope = activeConnectionId ? scope : "all";
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
if (!queries) return [];
|
||||||
|
if (effectiveScope === "all") return queries;
|
||||||
|
return queries.filter(
|
||||||
|
(q) => !q.connection_id || q.connection_id === activeConnectionId
|
||||||
|
);
|
||||||
|
}, [queries, effectiveScope, activeConnectionId]);
|
||||||
|
|
||||||
|
const connectionName = (id: string | undefined): string | undefined => {
|
||||||
|
if (!id) return undefined;
|
||||||
|
return connections.find((c) => c.id === id)?.name ?? id.slice(0, 8);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = (sql: string, queryConnectionId?: string) => {
|
||||||
|
const cid = activeConnectionId ?? queryConnectionId ?? "";
|
||||||
if (!cid) return;
|
if (!cid) return;
|
||||||
const tab: Tab = {
|
const tab: Tab = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -28,7 +50,7 @@ export function SavedQueriesPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center gap-1 p-2">
|
<div className="flex flex-col gap-1.5 p-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -38,45 +60,121 @@ export function SavedQueriesPanel() {
|
|||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{activeConnectionId && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<ScopeButton
|
||||||
|
active={scope === "active"}
|
||||||
|
onClick={() => setScope("active")}
|
||||||
|
>
|
||||||
|
This connection
|
||||||
|
</ScopeButton>
|
||||||
|
<ScopeButton
|
||||||
|
active={scope === "all"}
|
||||||
|
onClick={() => setScope("all")}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</ScopeButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
{queries?.map((query) => (
|
{visible.map((query) => (
|
||||||
<div
|
<QueryRow
|
||||||
key={query.id}
|
key={query.id}
|
||||||
className="group flex w-full flex-col gap-0.5 border-b px-3 py-2 text-left text-xs hover:bg-accent cursor-pointer"
|
query={query}
|
||||||
onDoubleClick={() => handleOpen(query.sql, query.connection_id)}
|
showConnectionTag={effectiveScope === "all"}
|
||||||
>
|
connectionLabel={connectionName(query.connection_id)}
|
||||||
<div className="flex items-center gap-1.5">
|
onOpen={() => handleOpen(query.sql, query.connection_id)}
|
||||||
<Bookmark className="h-3 w-3 shrink-0 text-blue-400" />
|
onDelete={() => deleteMutation.mutate(query.id)}
|
||||||
<span className="truncate font-medium text-foreground">
|
/>
|
||||||
{query.name}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="ml-auto h-5 w-5 shrink-0 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteMutation.mutate(query.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<span className="truncate font-mono text-muted-foreground">
|
|
||||||
{query.sql.length > 80 ? query.sql.slice(0, 80) + "..." : query.sql}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{new Date(query.created_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
{(!queries || queries.length === 0) && (
|
{visible.length === 0 && (
|
||||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||||
No saved queries
|
{effectiveScope === "active"
|
||||||
|
? "No saved queries for this connection"
|
||||||
|
: "No saved queries"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ScopeButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`h-6 rounded px-2 text-[11px] transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-accent text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QueryRow({
|
||||||
|
query,
|
||||||
|
showConnectionTag,
|
||||||
|
connectionLabel,
|
||||||
|
onOpen,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
query: SavedQuery;
|
||||||
|
showConnectionTag: boolean;
|
||||||
|
connectionLabel?: string;
|
||||||
|
onOpen: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const tag = !query.connection_id
|
||||||
|
? "unattached"
|
||||||
|
: showConnectionTag
|
||||||
|
? connectionLabel
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group flex w-full flex-col gap-0.5 border-b px-3 py-2 text-left text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onDoubleClick={onOpen}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Bookmark className="h-3 w-3 shrink-0 text-info" />
|
||||||
|
<span className="truncate font-medium text-foreground">
|
||||||
|
{query.name}
|
||||||
|
</span>
|
||||||
|
{tag && (
|
||||||
|
<span className="shrink-0 rounded bg-muted/40 px-1 py-px text-[9px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="ml-auto h-5 w-5 shrink-0 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<span className="truncate font-mono text-muted-foreground">
|
||||||
|
{query.sql.length > 80 ? query.sql.slice(0, 80) + "..." : query.sql}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{new Date(query.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ function SchemaNode({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<FolderOpen className="h-3.5 w-3.5 text-amber-500/70" />
|
<FolderOpen className="h-3.5 w-3.5 text-primary/70" />
|
||||||
) : (
|
) : (
|
||||||
<Database className="h-3.5 w-3.5 text-muted-foreground/50" />
|
<Database className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||||
)}
|
)}
|
||||||
@@ -328,10 +328,10 @@ function SchemaNode({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const categoryIcons = {
|
const categoryIcons = {
|
||||||
tables: <Table2 className="h-3.5 w-3.5 text-sky-400/80" />,
|
tables: <Table2 className="h-3.5 w-3.5 text-data-table" />,
|
||||||
views: <Eye className="h-3.5 w-3.5 text-emerald-400/80" />,
|
views: <Eye className="h-3.5 w-3.5 text-data-view" />,
|
||||||
functions: <FunctionSquare className="h-3.5 w-3.5 text-violet-400/80" />,
|
functions: <FunctionSquare className="h-3.5 w-3.5 text-data-function" />,
|
||||||
sequences: <Hash className="h-3.5 w-3.5 text-amber-400/80" />,
|
sequences: <Hash className="h-3.5 w-3.5 text-data-sequence" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
function CategoryNode({
|
function CategoryNode({
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { AiProvider, AppSettings } from "@/types";
|
|||||||
const SUPPORTED_AI_PROVIDERS: { value: AiProvider; label: string }[] = [
|
const SUPPORTED_AI_PROVIDERS: { value: AiProvider; label: string }[] = [
|
||||||
{ value: "ollama", label: "Ollama (local)" },
|
{ value: "ollama", label: "Ollama (local)" },
|
||||||
{ value: "fireworks", label: "Fireworks AI" },
|
{ value: "fireworks", label: "Fireworks AI" },
|
||||||
|
{ value: "openrouter", label: "OpenRouter" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -50,6 +51,7 @@ export function AppSettingsSheet({ open, onOpenChange }: Props) {
|
|||||||
const [aiProvider, setAiProvider] = useState<AiProvider>("ollama");
|
const [aiProvider, setAiProvider] = useState<AiProvider>("ollama");
|
||||||
const [ollamaUrl, setOllamaUrl] = useState("http://localhost:11434");
|
const [ollamaUrl, setOllamaUrl] = useState("http://localhost:11434");
|
||||||
const [fireworksApiKey, setFireworksApiKey] = useState("");
|
const [fireworksApiKey, setFireworksApiKey] = useState("");
|
||||||
|
const [openrouterApiKey, setOpenrouterApiKey] = useState("");
|
||||||
const [aiModel, setAiModel] = useState("");
|
const [aiModel, setAiModel] = useState("");
|
||||||
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -70,10 +72,14 @@ export function AppSettingsSheet({ open, onOpenChange }: Props) {
|
|||||||
if (aiSettings) {
|
if (aiSettings) {
|
||||||
// Legacy openai/anthropic values aren't user-selectable here — fall back to ollama.
|
// Legacy openai/anthropic values aren't user-selectable here — fall back to ollama.
|
||||||
setAiProvider(
|
setAiProvider(
|
||||||
aiSettings.provider === "fireworks" ? "fireworks" : "ollama"
|
aiSettings.provider === "fireworks" ||
|
||||||
|
aiSettings.provider === "openrouter"
|
||||||
|
? aiSettings.provider
|
||||||
|
: "ollama"
|
||||||
);
|
);
|
||||||
setOllamaUrl(aiSettings.ollama_url);
|
setOllamaUrl(aiSettings.ollama_url);
|
||||||
setFireworksApiKey(aiSettings.fireworks_api_key ?? "");
|
setFireworksApiKey(aiSettings.fireworks_api_key ?? "");
|
||||||
|
setOpenrouterApiKey(aiSettings.openrouter_api_key ?? "");
|
||||||
setAiModel(aiSettings.model);
|
setAiModel(aiSettings.model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,6 +121,10 @@ export function AppSettingsSheet({ open, onOpenChange }: Props) {
|
|||||||
aiProvider === "fireworks"
|
aiProvider === "fireworks"
|
||||||
? fireworksApiKey.trim() || undefined
|
? fireworksApiKey.trim() || undefined
|
||||||
: aiSettings?.fireworks_api_key,
|
: aiSettings?.fireworks_api_key,
|
||||||
|
openrouter_api_key:
|
||||||
|
aiProvider === "openrouter"
|
||||||
|
? openrouterApiKey.trim() || undefined
|
||||||
|
: aiSettings?.openrouter_api_key,
|
||||||
model: aiModel,
|
model: aiModel,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -167,7 +177,7 @@ export function AppSettingsSheet({ open, onOpenChange }: Props) {
|
|||||||
<span
|
<span
|
||||||
className={`inline-block h-2 w-2 rounded-full ${
|
className={`inline-block h-2 w-2 rounded-full ${
|
||||||
mcpStatus?.running
|
mcpStatus?.running
|
||||||
? "bg-green-500"
|
? "bg-success ring-2 ring-success/25"
|
||||||
: "bg-muted-foreground/30"
|
: "bg-muted-foreground/30"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -189,7 +199,7 @@ export function AppSettingsSheet({ open, onOpenChange }: Props) {
|
|||||||
title="Copy endpoint URL"
|
title="Copy endpoint URL"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<Check className="h-3 w-3 text-green-500" />
|
<Check className="h-3 w-3 text-success" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="h-3 w-3" />
|
<Copy className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
@@ -229,6 +239,8 @@ export function AppSettingsSheet({ open, onOpenChange }: Props) {
|
|||||||
onOllamaUrlChange={setOllamaUrl}
|
onOllamaUrlChange={setOllamaUrl}
|
||||||
fireworksApiKey={fireworksApiKey}
|
fireworksApiKey={fireworksApiKey}
|
||||||
onFireworksApiKeyChange={setFireworksApiKey}
|
onFireworksApiKeyChange={setFireworksApiKey}
|
||||||
|
openrouterApiKey={openrouterApiKey}
|
||||||
|
onOpenRouterApiKeyChange={setOpenrouterApiKey}
|
||||||
model={aiModel}
|
model={aiModel}
|
||||||
onModelChange={setAiModel}
|
onModelChange={setAiModel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -206,14 +206,14 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isReadOnly && (
|
{isReadOnly && (
|
||||||
<span className="flex items-center gap-1 rounded bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-600 dark:text-yellow-500">
|
<span className="flex items-center gap-1 rounded bg-warning/10 px-1.5 py-0.5 text-[10px] font-semibold text-warning">
|
||||||
<Lock className="h-3 w-3" />
|
<Lock className="h-3 w-3" />
|
||||||
Read-Only
|
Read-Only
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isReadOnly && usesCtid && (
|
{!isReadOnly && usesCtid && (
|
||||||
<span
|
<span
|
||||||
className="rounded bg-orange-500/10 px-1.5 py-0.5 text-[10px] font-medium text-orange-600 dark:text-orange-400"
|
className="rounded bg-warning/10 px-1.5 py-0.5 text-[10px] font-medium text-warning"
|
||||||
title="This table has no primary key. Edits use physical row ID (ctid), which may change after VACUUM or concurrent writes."
|
title="This table has no primary key. Edits use physical row ID (ctid), which may change after VACUUM or concurrent writes."
|
||||||
>
|
>
|
||||||
No PK — using ctid
|
No PK — using ctid
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useCompletionSchema } from "@/hooks/use-completion-schema";
|
|||||||
import { useConnections } from "@/hooks/use-connections";
|
import { useConnections } from "@/hooks/use-connections";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Play, Loader2, Lock, BarChart3, Download, AlignLeft, Bookmark, Table2, Braces, Sparkles, BrainCircuit } from "lucide-react";
|
import { Play, Loader2, Lock, BarChart3, Download, AlignLeft, Bookmark, Table2, Braces } from "lucide-react";
|
||||||
import { format as formatSql } from "sql-formatter";
|
import { format as formatSql } from "sql-formatter";
|
||||||
import { SaveQueryDialog } from "@/components/saved-queries/SaveQueryDialog";
|
import { SaveQueryDialog } from "@/components/saved-queries/SaveQueryDialog";
|
||||||
import {
|
import {
|
||||||
@@ -25,8 +25,6 @@ import {
|
|||||||
import { exportCsv, exportJson } from "@/lib/tauri";
|
import { exportCsv, exportJson } from "@/lib/tauri";
|
||||||
import { save } from "@tauri-apps/plugin-dialog";
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AiBar } from "@/components/ai/AiBar";
|
|
||||||
import { useExplainSql, useFixSqlError } from "@/hooks/use-ai";
|
|
||||||
import type { QueryResult, ExplainResult } from "@/types";
|
import type { QueryResult, ExplainResult } from "@/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -53,12 +51,8 @@ export function WorkspacePanel({
|
|||||||
const [resultView, setResultView] = useState<"results" | "explain">("results");
|
const [resultView, setResultView] = useState<"results" | "explain">("results");
|
||||||
const [resultViewMode, setResultViewMode] = useState<"table" | "json">("table");
|
const [resultViewMode, setResultViewMode] = useState<"table" | "json">("table");
|
||||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||||
const [aiBarOpen, setAiBarOpen] = useState(false);
|
|
||||||
const [aiExplanation, setAiExplanation] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const queryMutation = useQueryExecution();
|
const queryMutation = useQueryExecution();
|
||||||
const explainMutation = useExplainSql();
|
|
||||||
const fixMutation = useFixSqlError();
|
|
||||||
const addHistoryMutation = useAddHistory();
|
const addHistoryMutation = useAddHistory();
|
||||||
const { data: connections } = useConnections();
|
const { data: connections } = useConnections();
|
||||||
const { data: completionSchema } = useCompletionSchema(connectionId);
|
const { data: completionSchema } = useCompletionSchema(connectionId);
|
||||||
@@ -102,7 +96,6 @@ export function WorkspacePanel({
|
|||||||
if (!sqlValue.trim() || !connectionId) return;
|
if (!sqlValue.trim() || !connectionId) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setExplainData(null);
|
setExplainData(null);
|
||||||
setAiExplanation(null);
|
|
||||||
setResultView("results");
|
setResultView("results");
|
||||||
queryMutation.mutate(
|
queryMutation.mutate(
|
||||||
{ connectionId, sql: sqlValue },
|
{ connectionId, sql: sqlValue },
|
||||||
@@ -196,60 +189,6 @@ export function WorkspacePanel({
|
|||||||
[result]
|
[result]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isAiLoading = explainMutation.isPending || fixMutation.isPending;
|
|
||||||
|
|
||||||
const handleAiExplain = useCallback(() => {
|
|
||||||
if (!sqlValue.trim() || !connectionId) return;
|
|
||||||
setAiExplanation(null);
|
|
||||||
setResultView("results");
|
|
||||||
explainMutation.mutate(
|
|
||||||
{ connectionId, sql: sqlValue },
|
|
||||||
{
|
|
||||||
onSuccess: (explanation) => {
|
|
||||||
setAiExplanation(explanation);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error("AI Explain failed", { description: String(err) });
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [connectionId, sqlValue, explainMutation]);
|
|
||||||
|
|
||||||
const handleExplainError = useCallback(() => {
|
|
||||||
if (!sqlValue.trim() || !connectionId || !error) return;
|
|
||||||
setAiExplanation(null);
|
|
||||||
explainMutation.mutate(
|
|
||||||
{ connectionId, sql: `${sqlValue}\n\n-- Error: ${error}` },
|
|
||||||
{
|
|
||||||
onSuccess: (explanation) => {
|
|
||||||
setAiExplanation(explanation);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error("AI Explain failed", { description: String(err) });
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [connectionId, sqlValue, error, explainMutation]);
|
|
||||||
|
|
||||||
const handleFixError = useCallback(() => {
|
|
||||||
if (!sqlValue.trim() || !connectionId || !error) return;
|
|
||||||
fixMutation.mutate(
|
|
||||||
{ connectionId, sql: sqlValue, errorMessage: error },
|
|
||||||
{
|
|
||||||
onSuccess: (fixedSql) => {
|
|
||||||
setSqlValue(fixedSql);
|
|
||||||
onSqlChange?.(fixedSql);
|
|
||||||
setError(null);
|
|
||||||
setAiExplanation(null);
|
|
||||||
toast.success("SQL replaced by AI suggestion");
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error("AI Fix failed", { description: String(err) });
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [connectionId, sqlValue, error, fixMutation, onSqlChange]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResizablePanelGroup orientation="vertical">
|
<ResizablePanelGroup orientation="vertical">
|
||||||
@@ -308,35 +247,6 @@ export function WorkspacePanel({
|
|||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="mx-1 h-3.5 w-px bg-border/40" />
|
|
||||||
|
|
||||||
{/* AI actions group — purple-branded */}
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant={aiBarOpen ? "secondary" : "ghost"}
|
|
||||||
className={`gap-1 text-[11px] ${aiBarOpen ? "text-tusk-purple" : ""}`}
|
|
||||||
onClick={() => setAiBarOpen(!aiBarOpen)}
|
|
||||||
title="AI SQL Generator"
|
|
||||||
>
|
|
||||||
<Sparkles className={`h-3 w-3 ${aiBarOpen ? "tusk-ai-icon" : ""}`} />
|
|
||||||
AI
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
className="gap-1 text-[11px]"
|
|
||||||
onClick={handleAiExplain}
|
|
||||||
disabled={isAiLoading || !sqlValue.trim()}
|
|
||||||
title="Explain query with AI"
|
|
||||||
>
|
|
||||||
{isAiLoading ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<BrainCircuit className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
AI Explain
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{result && result.columns.length > 0 && (
|
{result && result.columns.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="mx-1 h-3.5 w-px bg-border/40" />
|
<div className="mx-1 h-3.5 w-px bg-border/40" />
|
||||||
@@ -369,23 +279,12 @@ export function WorkspacePanel({
|
|||||||
{"\u2318"}Enter
|
{"\u2318"}Enter
|
||||||
</span>
|
</span>
|
||||||
{isReadOnly && (
|
{isReadOnly && (
|
||||||
<span className="ml-2 flex items-center gap-1 rounded-sm bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-amber-500">
|
<span className="ml-2 flex items-center gap-1 rounded-sm bg-warning/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-warning">
|
||||||
<Lock className="h-2.5 w-2.5" />
|
<Lock className="h-2.5 w-2.5" />
|
||||||
READ
|
READ
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{aiBarOpen && (
|
|
||||||
<AiBar
|
|
||||||
connectionId={connectionId}
|
|
||||||
onSqlGenerated={(sql) => {
|
|
||||||
setSqlValue(sql);
|
|
||||||
onSqlChange?.(sql);
|
|
||||||
}}
|
|
||||||
onClose={() => setAiBarOpen(false)}
|
|
||||||
onExecute={handleExecute}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<SqlEditor
|
<SqlEditor
|
||||||
value={sqlValue}
|
value={sqlValue}
|
||||||
@@ -400,7 +299,7 @@ export function WorkspacePanel({
|
|||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
<ResizablePanel id="results" defaultSize="60%" minSize="15%">
|
<ResizablePanel id="results" defaultSize="60%" minSize="15%">
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
{(explainData || result || error || aiExplanation) && (
|
{(explainData || result || error) && (
|
||||||
<div className="flex shrink-0 items-center border-b border-border/40 text-xs">
|
<div className="flex shrink-0 items-center border-b border-border/40 text-xs">
|
||||||
<button
|
<button
|
||||||
className={`relative px-3 py-1.5 font-medium transition-colors ${
|
className={`relative px-3 py-1.5 font-medium transition-colors ${
|
||||||
@@ -469,10 +368,6 @@ export function WorkspacePanel({
|
|||||||
error={error}
|
error={error}
|
||||||
isLoading={queryMutation.isPending && resultView === "results"}
|
isLoading={queryMutation.isPending && resultView === "results"}
|
||||||
viewMode={resultViewMode}
|
viewMode={resultViewMode}
|
||||||
aiExplanation={aiExplanation}
|
|
||||||
isAiLoading={isAiLoading}
|
|
||||||
onExplainError={error ? handleExplainError : undefined}
|
|
||||||
onFixError={error ? handleFixError : undefined}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import {
|
|||||||
saveAiSettings,
|
saveAiSettings,
|
||||||
listOllamaModels,
|
listOllamaModels,
|
||||||
listFireworksModels,
|
listFireworksModels,
|
||||||
generateSql,
|
listOpenRouterModels,
|
||||||
explainSql,
|
|
||||||
fixSqlError,
|
|
||||||
} from "@/lib/tauri";
|
} from "@/lib/tauri";
|
||||||
import type { AiSettings } from "@/types";
|
import type { AiSettings } from "@/types";
|
||||||
|
|
||||||
@@ -47,40 +45,12 @@ export function useFireworksModels(apiKey: string | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGenerateSql() {
|
export function useOpenRouterModels(apiKey: string | undefined) {
|
||||||
return useMutation({
|
return useQuery({
|
||||||
mutationFn: ({
|
queryKey: ["openrouter-models", apiKey],
|
||||||
connectionId,
|
queryFn: () => listOpenRouterModels(apiKey!),
|
||||||
prompt,
|
enabled: !!apiKey && apiKey.trim().length > 0,
|
||||||
}: {
|
retry: false,
|
||||||
connectionId: string;
|
staleTime: 60_000,
|
||||||
prompt: string;
|
|
||||||
}) => generateSql(connectionId, prompt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useExplainSql() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
connectionId,
|
|
||||||
sql,
|
|
||||||
}: {
|
|
||||||
connectionId: string;
|
|
||||||
sql: string;
|
|
||||||
}) => explainSql(connectionId, sql),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFixSqlError() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
connectionId,
|
|
||||||
sql,
|
|
||||||
errorMessage,
|
|
||||||
}: {
|
|
||||||
connectionId: string;
|
|
||||||
sql: string;
|
|
||||||
errorMessage: string;
|
|
||||||
}) => fixSqlError(connectionId, sql, errorMessage),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
102
src/lib/editor-theme.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||||
|
import { tags as t } from "@lezer/highlight";
|
||||||
|
import type { Extension } from "@codemirror/state";
|
||||||
|
|
||||||
|
/* ───────────────────────────────────────────────────────────
|
||||||
|
Tusk "Graphite & Honey" CodeMirror theme.
|
||||||
|
Palette mirrors the design tokens in styles/globals.css so the
|
||||||
|
SQL editor sits seamlessly inside the warm graphite workstation.
|
||||||
|
─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const c = {
|
||||||
|
bg: "oklch(0.186 0.006 75)",
|
||||||
|
surface: "oklch(0.205 0.007 75)",
|
||||||
|
fg: "oklch(0.9 0.013 80)",
|
||||||
|
faint: "oklch(0.52 0.012 75)",
|
||||||
|
gutter: "oklch(0.46 0.011 75)",
|
||||||
|
gutterActive: "oklch(0.74 0.012 80)",
|
||||||
|
cursor: "oklch(0.84 0.13 82)",
|
||||||
|
selection: "oklch(0.808 0.124 82 / 22%)",
|
||||||
|
activeLine: "oklch(0.808 0.124 82 / 5%)",
|
||||||
|
activeGutter: "oklch(0.808 0.124 82 / 9%)",
|
||||||
|
matchBg: "oklch(0.808 0.124 82 / 18%)",
|
||||||
|
|
||||||
|
// syntax
|
||||||
|
keyword: "oklch(0.82 0.125 82)", // honey — SELECT, FROM, WHERE
|
||||||
|
string: "oklch(0.76 0.13 152)", // green
|
||||||
|
number: "oklch(0.74 0.12 222)", // cyan-blue
|
||||||
|
func: "oklch(0.74 0.15 305)", // violet — built-ins
|
||||||
|
type: "oklch(0.74 0.12 200)", // teal-cyan — types
|
||||||
|
comment: "oklch(0.5 0.012 75)", // muted, italic
|
||||||
|
operator: "oklch(0.74 0.013 80)",
|
||||||
|
bracket: "oklch(0.66 0.012 78)",
|
||||||
|
invalid: "oklch(0.66 0.2 24)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const tuskEditorTheme = EditorView.theme(
|
||||||
|
{
|
||||||
|
"&": {
|
||||||
|
color: c.fg,
|
||||||
|
backgroundColor: c.bg,
|
||||||
|
},
|
||||||
|
".cm-content": {
|
||||||
|
caretColor: c.cursor,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
padding: "8px 0",
|
||||||
|
},
|
||||||
|
".cm-cursor, .cm-dropCursor": { borderLeftColor: c.cursor, borderLeftWidth: "2px" },
|
||||||
|
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
|
||||||
|
{ backgroundColor: c.selection },
|
||||||
|
".cm-activeLine": { backgroundColor: c.activeLine },
|
||||||
|
".cm-gutters": {
|
||||||
|
backgroundColor: c.bg,
|
||||||
|
color: c.gutter,
|
||||||
|
border: "none",
|
||||||
|
borderRight: "1px solid oklch(0.34 0.011 75 / 50%)",
|
||||||
|
},
|
||||||
|
".cm-activeLineGutter": {
|
||||||
|
backgroundColor: c.activeGutter,
|
||||||
|
color: c.gutterActive,
|
||||||
|
},
|
||||||
|
".cm-foldGutter .cm-gutterElement": { color: c.faint },
|
||||||
|
".cm-selectionMatch": { backgroundColor: c.matchBg },
|
||||||
|
"&.cm-focused .cm-matchingBracket": {
|
||||||
|
backgroundColor: c.matchBg,
|
||||||
|
outline: "1px solid oklch(0.808 0.124 82 / 45%)",
|
||||||
|
borderRadius: "2px",
|
||||||
|
},
|
||||||
|
".cm-line": { padding: "0 4px 0 8px" },
|
||||||
|
".cm-scroller": { fontFamily: "var(--font-mono)" },
|
||||||
|
".cm-panels": { backgroundColor: c.surface, color: c.fg },
|
||||||
|
".cm-searchMatch": {
|
||||||
|
backgroundColor: "oklch(0.78 0.135 65 / 28%)",
|
||||||
|
outline: "1px solid oklch(0.78 0.135 65 / 50%)",
|
||||||
|
},
|
||||||
|
".cm-searchMatch.cm-searchMatch-selected": {
|
||||||
|
backgroundColor: "oklch(0.808 0.124 82 / 35%)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ dark: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const tuskHighlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: [t.keyword, t.operatorKeyword, t.modifier], color: c.keyword, fontWeight: "500" },
|
||||||
|
{ tag: [t.string, t.special(t.string), t.character], color: c.string },
|
||||||
|
{ tag: [t.number, t.bool, t.null], color: c.number },
|
||||||
|
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: c.func },
|
||||||
|
{ tag: [t.typeName, t.className, t.namespace], color: c.type },
|
||||||
|
{ tag: [t.comment, t.lineComment, t.blockComment], color: c.comment, fontStyle: "italic" },
|
||||||
|
{ tag: [t.operator, t.compareOperator, t.arithmeticOperator, t.logicOperator], color: c.operator },
|
||||||
|
{ tag: [t.bracket, t.paren, t.squareBracket, t.brace, t.punctuation], color: c.bracket },
|
||||||
|
{ tag: [t.propertyName, t.attributeName], color: c.fg },
|
||||||
|
{ tag: [t.variableName, t.name], color: c.fg },
|
||||||
|
{ tag: [t.definitionKeyword], color: c.keyword, fontWeight: "500" },
|
||||||
|
{ tag: [t.invalid], color: c.invalid, textDecoration: "underline wavy" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Full Tusk editor theme: base UI styling + SQL syntax highlighting. */
|
||||||
|
export const tuskEditorExtensions: Extension = [
|
||||||
|
tuskEditorTheme,
|
||||||
|
syntaxHighlighting(tuskHighlightStyle),
|
||||||
|
];
|
||||||
@@ -214,14 +214,8 @@ export const listOllamaModels = (ollamaUrl: string) =>
|
|||||||
export const listFireworksModels = (apiKey: string) =>
|
export const listFireworksModels = (apiKey: string) =>
|
||||||
invoke<OllamaModel[]>("list_fireworks_models", { apiKey });
|
invoke<OllamaModel[]>("list_fireworks_models", { apiKey });
|
||||||
|
|
||||||
export const generateSql = (connectionId: string, prompt: string) =>
|
export const listOpenRouterModels = (apiKey: string) =>
|
||||||
invoke<string>("generate_sql", { connectionId, prompt });
|
invoke<OllamaModel[]>("list_openrouter_models", { apiKey });
|
||||||
|
|
||||||
export const explainSql = (connectionId: string, sql: string) =>
|
|
||||||
invoke<string>("explain_sql", { connectionId, sql });
|
|
||||||
|
|
||||||
export const fixSqlError = (connectionId: string, sql: string, errorMessage: string) =>
|
|
||||||
invoke<string>("fix_sql_error", { connectionId, sql, errorMessage });
|
|
||||||
|
|
||||||
export const chatSend = (connectionId: string, messages: ChatMessage[]) =>
|
export const chatSend = (connectionId: string, messages: ChatMessage[]) =>
|
||||||
invoke<ChatTurnResult>("chat_send", { connectionId, messages });
|
invoke<ChatTurnResult>("chat_send", { connectionId, messages });
|
||||||
|
|||||||
@@ -5,18 +5,18 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
TUSK — "Twilight" Design System
|
TUSK — "Graphite & Honey" Design System
|
||||||
Soft dark with blue undertones and teal accents
|
Warm graphite workstation · ivory text · honey accent
|
||||||
|
Monospace-first, tuned for long data + query sessions.
|
||||||
═══════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 3px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 1px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
--radius-2xl: calc(var(--radius) + 8px);
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
--radius-3xl: calc(var(--radius) + 12px);
|
|
||||||
--radius-4xl: calc(var(--radius) + 16px);
|
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
@@ -49,72 +49,87 @@
|
|||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
|
||||||
/* Custom semantic tokens */
|
/* Brand + semantic status tokens */
|
||||||
--color-tusk-teal: var(--tusk-teal);
|
--color-honey: var(--honey);
|
||||||
--color-tusk-purple: var(--tusk-purple);
|
--color-success: var(--success);
|
||||||
--color-tusk-amber: var(--tusk-amber);
|
--color-warning: var(--warning);
|
||||||
--color-tusk-rose: var(--tusk-rose);
|
--color-info: var(--info);
|
||||||
--color-tusk-surface: var(--tusk-surface);
|
--color-violet: var(--violet);
|
||||||
|
|
||||||
/* Font families */
|
/* Database object categories (schema tree, syntax, etc.) */
|
||||||
--font-sans: "Outfit", system-ui, -apple-system, sans-serif;
|
--color-data-table: var(--data-table);
|
||||||
--font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace;
|
--color-data-view: var(--data-view);
|
||||||
|
--color-data-function: var(--data-function);
|
||||||
|
--color-data-sequence: var(--data-sequence);
|
||||||
|
|
||||||
|
/* Font families — monospace everywhere, "like for development" */
|
||||||
|
--font-sans: "IBM Plex Mono", "JetBrains Mono", ui-monospace, "SF Mono",
|
||||||
|
"Cascadia Code", monospace;
|
||||||
|
--font-mono: "IBM Plex Mono", "JetBrains Mono", ui-monospace, "SF Mono",
|
||||||
|
"Cascadia Code", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
/* Soft twilight palette — comfortable, not eye-straining */
|
/* ── Warm graphite surfaces (hue ~75, very low chroma) ─────────
|
||||||
--background: oklch(0.2 0.012 250);
|
Layered from deepest (app) to highest (popover). Never pure
|
||||||
--foreground: oklch(0.9 0.005 250);
|
black — warm charcoal reduces halation over long sessions. */
|
||||||
--card: oklch(0.23 0.012 250);
|
--background: oklch(0.176 0.006 75);
|
||||||
--card-foreground: oklch(0.9 0.005 250);
|
--foreground: oklch(0.912 0.013 80);
|
||||||
--popover: oklch(0.25 0.014 250);
|
--card: oklch(0.205 0.007 75);
|
||||||
--popover-foreground: oklch(0.9 0.005 250);
|
--card-foreground: oklch(0.912 0.013 80);
|
||||||
|
--popover: oklch(0.232 0.008 75);
|
||||||
|
--popover-foreground: oklch(0.93 0.012 80);
|
||||||
|
|
||||||
/* Teal primary — slightly softer for the lighter background */
|
/* ── Honey primary — warm, inviting, easy on the eyes ───────── */
|
||||||
--primary: oklch(0.72 0.14 170);
|
--primary: oklch(0.808 0.124 82);
|
||||||
--primary-foreground: oklch(0.18 0.015 250);
|
--primary-foreground: oklch(0.21 0.03 80);
|
||||||
|
|
||||||
/* Surfaces — gentle stepping */
|
/* ── Subtle fills + hover surfaces ──────────────────────────── */
|
||||||
--secondary: oklch(0.27 0.012 250);
|
--secondary: oklch(0.255 0.008 75);
|
||||||
--secondary-foreground: oklch(0.85 0.008 250);
|
--secondary-foreground: oklch(0.88 0.012 80);
|
||||||
--muted: oklch(0.27 0.012 250);
|
--muted: oklch(0.255 0.008 75);
|
||||||
--muted-foreground: oklch(0.62 0.015 250);
|
--muted-foreground: oklch(0.638 0.013 78);
|
||||||
--accent: oklch(0.28 0.014 250);
|
--accent: oklch(0.285 0.01 75);
|
||||||
--accent-foreground: oklch(0.9 0.005 250);
|
--accent-foreground: oklch(0.93 0.012 80);
|
||||||
|
|
||||||
/* Status */
|
--destructive: oklch(0.655 0.196 24);
|
||||||
--destructive: oklch(0.65 0.2 15);
|
|
||||||
|
|
||||||
/* Borders & inputs — more visible, less transparent */
|
/* ── Borders + inputs — visible but quiet ───────────────────── */
|
||||||
--border: oklch(0.34 0.015 250 / 70%);
|
--border: oklch(0.34 0.011 75 / 65%);
|
||||||
--input: oklch(0.36 0.015 250 / 60%);
|
--input: oklch(0.36 0.011 75 / 55%);
|
||||||
--ring: oklch(0.72 0.14 170 / 40%);
|
--ring: oklch(0.808 0.124 82 / 45%);
|
||||||
|
|
||||||
/* Chart palette */
|
/* ── Charts — honey-led harmonious set ──────────────────────── */
|
||||||
--chart-1: oklch(0.72 0.14 170);
|
--chart-1: oklch(0.808 0.124 82);
|
||||||
--chart-2: oklch(0.68 0.14 200);
|
--chart-2: oklch(0.7 0.12 220);
|
||||||
--chart-3: oklch(0.78 0.14 85);
|
--chart-3: oklch(0.74 0.13 152);
|
||||||
--chart-4: oklch(0.62 0.18 290);
|
--chart-4: oklch(0.7 0.15 305);
|
||||||
--chart-5: oklch(0.68 0.16 30);
|
--chart-5: oklch(0.7 0.16 28);
|
||||||
|
|
||||||
/* Sidebar <EFBFBD><EFBFBD> same family, slightly offset */
|
/* ── Sidebar — one notch deeper than the main canvas ────────── */
|
||||||
--sidebar: oklch(0.215 0.012 250);
|
--sidebar: oklch(0.192 0.006 75);
|
||||||
--sidebar-foreground: oklch(0.9 0.005 250);
|
--sidebar-foreground: oklch(0.9 0.012 80);
|
||||||
--sidebar-primary: oklch(0.72 0.14 170);
|
--sidebar-primary: oklch(0.808 0.124 82);
|
||||||
--sidebar-primary-foreground: oklch(0.9 0.005 250);
|
--sidebar-primary-foreground: oklch(0.21 0.03 80);
|
||||||
--sidebar-accent: oklch(0.28 0.014 250);
|
--sidebar-accent: oklch(0.285 0.01 75);
|
||||||
--sidebar-accent-foreground: oklch(0.9 0.005 250);
|
--sidebar-accent-foreground: oklch(0.93 0.012 80);
|
||||||
--sidebar-border: oklch(0.34 0.015 250 / 70%);
|
--sidebar-border: oklch(0.34 0.011 75 / 60%);
|
||||||
--sidebar-ring: oklch(0.72 0.14 170 / 40%);
|
--sidebar-ring: oklch(0.808 0.124 82 / 45%);
|
||||||
|
|
||||||
/* Tusk semantic tokens */
|
/* ── Brand + semantic status ────────────────────────────────── */
|
||||||
--tusk-teal: oklch(0.72 0.14 170);
|
--honey: oklch(0.808 0.124 82);
|
||||||
--tusk-purple: oklch(0.62 0.2 290);
|
--success: oklch(0.74 0.14 152);
|
||||||
--tusk-amber: oklch(0.78 0.14 85);
|
--warning: oklch(0.78 0.135 65);
|
||||||
--tusk-rose: oklch(0.65 0.2 15);
|
--info: oklch(0.72 0.12 222);
|
||||||
--tusk-surface: oklch(0.26 0.012 250);
|
--violet: oklch(0.72 0.15 305);
|
||||||
|
|
||||||
|
/* ── Database object categories ─────────────────────────────── */
|
||||||
|
--data-table: oklch(0.72 0.12 222);
|
||||||
|
--data-view: oklch(0.74 0.13 152);
|
||||||
|
--data-function: oklch(0.72 0.15 305);
|
||||||
|
--data-sequence: oklch(0.79 0.13 70);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
@@ -129,6 +144,8 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
font-feature-settings: "ss02" 1, "zero" 1; /* slashed zero, alt glyphs */
|
||||||
|
letter-spacing: -0.006em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -136,39 +153,53 @@
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Monospace for code and data */
|
/* Tabular figures everywhere data is shown — columns line up */
|
||||||
code, pre, .font-mono,
|
code, pre, .font-mono, table, input, [data-slot="sql-editor"], .cm-editor {
|
||||||
[data-slot="sql-editor"],
|
font-variant-numeric: tabular-nums;
|
||||||
.cm-editor {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smoother scrollbars */
|
/* Smoother, quieter scrollbars */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 7px;
|
||||||
height: 6px;
|
height: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: oklch(0.42 0.015 250 / 45%);
|
background: oklch(0.42 0.01 75 / 45%);
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: oklch(0.5 0.015 250 / 60%);
|
background: oklch(0.52 0.012 75 / 60%);
|
||||||
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-corner {
|
::-webkit-scrollbar-corner {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
Noise texture overlay — very subtle depth
|
Wordmark — heavy, tracked-out mono
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.tusk-wordmark {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section / eyebrow labels — small uppercase mono */
|
||||||
|
.tusk-eyebrow {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Noise texture overlay — very subtle warm depth
|
||||||
═══════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
.tusk-noise::before {
|
.tusk-noise::before {
|
||||||
@@ -177,38 +208,42 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.018;
|
opacity: 0.022;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
background-repeat: repeat;
|
background-repeat: repeat;
|
||||||
background-size: 256px 256px;
|
background-size: 256px 256px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Faint warm light bloom from the top — atmosphere, not decoration */
|
||||||
|
.tusk-noise::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 80% at 50% -20%, oklch(0.808 0.124 82 / 4.5%), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
Glow effects — softer for lighter background
|
Glow effects — honey-led
|
||||||
═══════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
.tusk-glow-teal {
|
.tusk-glow-honey {
|
||||||
box-shadow: 0 0 10px oklch(0.72 0.14 170 / 12%),
|
box-shadow: 0 0 12px oklch(0.808 0.124 82 / 14%),
|
||||||
0 0 3px oklch(0.72 0.14 170 / 8%);
|
0 0 3px oklch(0.808 0.124 82 / 9%);
|
||||||
|
}
|
||||||
|
.tusk-glow-honey-subtle {
|
||||||
|
box-shadow: 0 0 7px oklch(0.808 0.124 82 / 7%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tusk-glow-purple {
|
/* ═══════════════════════════════════════════════════════
|
||||||
box-shadow: 0 0 10px oklch(0.62 0.2 290 / 15%),
|
Active tab indicator — top honey bar
|
||||||
0 0 3px oklch(0.62 0.2 290 / 10%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tusk-glow-teal-subtle {
|
|
||||||
box-shadow: 0 0 6px oklch(0.72 0.14 170 / 6%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════<E29590><E29590>═══════
|
|
||||||
Active tab indicator — top glow bar
|
|
||||||
═══════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
.tusk-tab-active {
|
.tusk-tab-active {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tusk-tab-active::after {
|
.tusk-tab-active::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -216,35 +251,34 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: linear-gradient(90deg, oklch(0.72 0.14 170), oklch(0.68 0.14 200));
|
background: linear-gradient(90deg, oklch(0.808 0.124 82), oklch(0.79 0.13 70));
|
||||||
border-radius: 0 0 2px 2px;
|
box-shadow: 0 0 8px oklch(0.808 0.124 82 / 35%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
AI feature branding — purple glow language
|
AI feature branding — violet language
|
||||||
═══════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
.tusk-ai-bar {
|
.tusk-ai-bar {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
oklch(0.62 0.2 290 / 5%) 0%,
|
oklch(0.72 0.15 305 / 6%) 0%,
|
||||||
oklch(0.62 0.2 290 / 2%) 50%,
|
oklch(0.72 0.15 305 / 2%) 50%,
|
||||||
oklch(0.72 0.14 170 / 3%) 100%
|
oklch(0.808 0.124 82 / 3%) 100%
|
||||||
);
|
);
|
||||||
border-bottom: 1px solid oklch(0.62 0.2 290 / 12%);
|
border-bottom: 1px solid oklch(0.72 0.15 305 / 14%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tusk-ai-icon {
|
.tusk-ai-icon {
|
||||||
color: oklch(0.68 0.18 290);
|
color: oklch(0.74 0.15 305);
|
||||||
filter: drop-shadow(0 0 3px oklch(0.62 0.2 290 / 30%));
|
filter: drop-shadow(0 0 4px oklch(0.72 0.15 305 / 35%));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
Transitions — smooth everything
|
Transitions
|
||||||
═══════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
button, a, [role="tab"], [role="menuitem"], [data-slot="button"] {
|
button, a, [role="tab"], [role="menuitem"], [data-slot="button"] {
|
||||||
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 140ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
@@ -255,8 +289,8 @@ button, a, [role="tab"], [role="menuitem"], [data-slot="button"] {
|
|||||||
[data-radix-popper-content-wrapper] [role="listbox"],
|
[data-radix-popper-content-wrapper] [role="listbox"],
|
||||||
[data-radix-popper-content-wrapper] [role="menu"],
|
[data-radix-popper-content-wrapper] [role="menu"],
|
||||||
[data-state="open"][data-side] {
|
[data-state="open"][data-side] {
|
||||||
backdrop-filter: blur(16px) saturate(1.2);
|
backdrop-filter: blur(16px) saturate(1.15);
|
||||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
-webkit-backdrop-filter: blur(16px) saturate(1.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
@@ -266,47 +300,64 @@ button, a, [role="tab"], [role="menuitem"], [data-slot="button"] {
|
|||||||
.tusk-sidebar-tab-active {
|
.tusk-sidebar-tab-active {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tusk-sidebar-tab-active::after {
|
.tusk-sidebar-tab-active::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 25%;
|
left: 22%;
|
||||||
right: 25%;
|
right: 22%;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: oklch(0.72 0.14 170);
|
background: oklch(0.808 0.124 82);
|
||||||
border-radius: 2px 2px 0 0;
|
border-radius: 2px 2px 0 0;
|
||||||
|
box-shadow: 0 0 6px oklch(0.808 0.124 82 / 40%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
Data grid refinements
|
Data grid — the surface that matters most
|
||||||
═══════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
.tusk-grid-header {
|
.tusk-grid-header {
|
||||||
background: oklch(0.23 0.012 250);
|
background: oklch(0.232 0.008 75);
|
||||||
border-bottom: 1px solid oklch(0.34 0.015 250 / 80%);
|
border-bottom: 1px solid oklch(0.4 0.012 75 / 70%);
|
||||||
|
box-shadow: 0 1px 0 oklch(0 0 0 / 25%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tusk-grid-row {
|
||||||
|
border-bottom: 1px solid oklch(0.3 0.008 75 / 35%);
|
||||||
|
}
|
||||||
|
/* Zebra striping for fast horizontal scanning */
|
||||||
|
.tusk-grid-row-odd {
|
||||||
|
background: oklch(0.205 0.007 75 / 45%);
|
||||||
|
}
|
||||||
.tusk-grid-row:hover {
|
.tusk-grid-row:hover {
|
||||||
background: oklch(0.72 0.14 170 / 5%);
|
background: oklch(0.808 0.124 82 / 8%);
|
||||||
|
box-shadow: inset 2px 0 0 oklch(0.808 0.124 82 / 55%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tusk-grid-cell-null {
|
.tusk-grid-cell-null {
|
||||||
color: oklch(0.5 0.015 250);
|
color: oklch(0.52 0.012 75);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tusk-grid-cell-highlight {
|
.tusk-grid-cell-highlight {
|
||||||
background: oklch(0.78 0.14 85 / 10%);
|
background: oklch(0.808 0.124 82 / 16%);
|
||||||
|
box-shadow: inset 0 0 0 1px oklch(0.808 0.124 82 / 35%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
Status bar
|
Status bar / toolbar chrome
|
||||||
═══════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
.tusk-status-bar {
|
.tusk-status-bar {
|
||||||
background: oklch(0.215 0.012 250);
|
background: oklch(0.192 0.006 75);
|
||||||
border-top: 1px solid oklch(0.34 0.015 250 / 50%);
|
border-top: 1px solid oklch(0.34 0.011 75 / 55%);
|
||||||
|
}
|
||||||
|
.tusk-toolbar {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
oklch(0.222 0.008 75),
|
||||||
|
oklch(0.205 0.007 75)
|
||||||
|
);
|
||||||
|
border-bottom: 1px solid oklch(0.34 0.011 75 / 60%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
@@ -316,7 +367,6 @@ button, a, [role="tab"], [role="menuitem"], [data-slot="button"] {
|
|||||||
.tusk-conn-strip {
|
.tusk-conn-strip {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tusk-conn-strip::before {
|
.tusk-conn-strip::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -325,84 +375,57 @@ button, a, [role="tab"], [role="menuitem"], [data-slot="button"] {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: var(--strip-width, 3px);
|
width: var(--strip-width, 3px);
|
||||||
background: var(--strip-color, transparent);
|
background: var(--strip-color, transparent);
|
||||||
border-radius: 0 2px 2px 0;
|
box-shadow: 0 0 8px var(--strip-color, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
Toolbar
|
Resizable handles
|
||||||
═══════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
.tusk-toolbar {
|
|
||||||
background: oklch(0.23 0.012 250);
|
|
||||||
border-bottom: 1px solid oklch(0.34 0.015 250 / 60%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
|
||||||
Resizable handle
|
|
||||||
═══════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
[data-panel-group-direction="horizontal"] > [data-resize-handle] {
|
[data-panel-group-direction="horizontal"] > [data-resize-handle] {
|
||||||
width: 1px !important;
|
width: 1px !important;
|
||||||
background: oklch(0.34 0.015 250 / 50%);
|
background: oklch(0.34 0.011 75 / 55%);
|
||||||
transition: background 200ms, width 200ms;
|
transition: background 180ms, width 180ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-panel-group-direction="horizontal"] > [data-resize-handle]:hover,
|
[data-panel-group-direction="horizontal"] > [data-resize-handle]:hover,
|
||||||
[data-panel-group-direction="horizontal"] > [data-resize-handle][data-resize-handle-active] {
|
[data-panel-group-direction="horizontal"] > [data-resize-handle][data-resize-handle-active] {
|
||||||
width: 3px !important;
|
width: 3px !important;
|
||||||
background: oklch(0.72 0.14 170 / 60%);
|
background: oklch(0.808 0.124 82 / 65%);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-panel-group-direction="vertical"] > [data-resize-handle] {
|
[data-panel-group-direction="vertical"] > [data-resize-handle] {
|
||||||
height: 1px !important;
|
height: 1px !important;
|
||||||
background: oklch(0.34 0.015 250 / 50%);
|
background: oklch(0.34 0.011 75 / 55%);
|
||||||
transition: background 200ms, height 200ms;
|
transition: background 180ms, height 180ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-panel-group-direction="vertical"] > [data-resize-handle]:hover,
|
[data-panel-group-direction="vertical"] > [data-resize-handle]:hover,
|
||||||
[data-panel-group-direction="vertical"] > [data-resize-handle][data-resize-handle-active] {
|
[data-panel-group-direction="vertical"] > [data-resize-handle][data-resize-handle-active] {
|
||||||
height: 3px !important;
|
height: 3px !important;
|
||||||
background: oklch(0.72 0.14 170 / 60%);
|
background: oklch(0.808 0.124 82 / 65%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
CodeMirror theme overrides
|
CodeMirror — autocomplete + gutter polish
|
||||||
|
(base colors & syntax live in src/lib/editor-theme.ts)
|
||||||
═══════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
.cm-editor {
|
.cm-editor {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-editor .cm-gutters {
|
|
||||||
background: oklch(0.215 0.012 250);
|
|
||||||
border-right: 1px solid oklch(0.34 0.015 250 / 50%);
|
|
||||||
color: oklch(0.48 0.012 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor .cm-activeLineGutter {
|
|
||||||
background: oklch(0.72 0.14 170 / 8%);
|
|
||||||
color: oklch(0.65 0.015 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor .cm-activeLine {
|
|
||||||
background: oklch(0.72 0.14 170 / 4%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor .cm-cursor {
|
|
||||||
border-left-color: oklch(0.72 0.14 170);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor .cm-selectionBackground {
|
|
||||||
background: oklch(0.72 0.14 170 / 15%) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor .cm-tooltip-autocomplete {
|
.cm-editor .cm-tooltip-autocomplete {
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
background: oklch(0.25 0.014 250 / 95%);
|
background: oklch(0.232 0.008 75 / 96%);
|
||||||
border: 1px solid oklch(0.34 0.015 250 / 70%);
|
border: 1px solid oklch(0.34 0.011 75 / 70%);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 8px 32px oklch(0 0 0 / 30%);
|
box-shadow: 0 10px 36px oklch(0 0 0 / 38%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cm-editor .cm-tooltip-autocomplete > ul > li[aria-selected] {
|
||||||
|
background: oklch(0.808 0.124 82 / 16%);
|
||||||
|
color: oklch(0.93 0.012 80);
|
||||||
|
}
|
||||||
|
.cm-editor .cm-completionLabel {
|
||||||
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
@@ -422,16 +445,13 @@ button, a, [role="tab"], [role="menuitem"], [data-slot="button"] {
|
|||||||
from { opacity: 0; transform: translateY(4px); }
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes tusk-pulse-glow {
|
@keyframes tusk-pulse-glow {
|
||||||
0%, 100% { opacity: 0.6; }
|
0%, 100% { opacity: 0.55; }
|
||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.tusk-fade-in {
|
.tusk-fade-in {
|
||||||
animation: tusk-fade-in 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
animation: tusk-fade-in 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tusk-pulse-glow {
|
.tusk-pulse-glow {
|
||||||
animation: tusk-pulse-glow 2s ease-in-out infinite;
|
animation: tusk-pulse-glow 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -441,6 +461,6 @@ button, a, [role="tab"], [role="menuitem"], [data-slot="button"] {
|
|||||||
═══════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: oklch(0.72 0.14 170 / 25%);
|
background: oklch(0.808 0.124 82 / 26%);
|
||||||
color: oklch(0.95 0.005 250);
|
color: oklch(0.96 0.01 80);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,14 +134,13 @@ export interface SavedQuery {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AiProvider = "ollama" | "openai" | "anthropic" | "fireworks";
|
export type AiProvider = "ollama" | "fireworks" | "openrouter";
|
||||||
|
|
||||||
export interface AiSettings {
|
export interface AiSettings {
|
||||||
provider: AiProvider;
|
provider: AiProvider;
|
||||||
ollama_url: string;
|
ollama_url: string;
|
||||||
openai_api_key?: string;
|
|
||||||
anthropic_api_key?: string;
|
|
||||||
fireworks_api_key?: string;
|
fireworks_api_key?: string;
|
||||||
|
openrouter_api_key?: string;
|
||||||
model: string;
|
model: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,14 +215,3 @@ export interface ChatTurnResult {
|
|||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
usage: ContextUsage;
|
usage: ContextUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChartType = "bar" | "line" | "area" | "pie";
|
|
||||||
|
|
||||||
export interface ChartConfig {
|
|
||||||
chart_type: ChartType;
|
|
||||||
x: string;
|
|
||||||
y: string;
|
|
||||||
group?: string | null;
|
|
||||||
title?: string | null;
|
|
||||||
orientation?: string | null;
|
|
||||||
}
|
|
||||||
|
|||||||