Removes enterprise/DBA features and replaces the marginal AI bar with a central chat agent that has progressive-discovery tools, cross-session memory, saved-query reuse, and inline result actions. Adds ClickHouse support alongside PostgreSQL/Greenplum. Cleanup - Drop ~10k LOC of advanced features: Docker, Snapshots, Validation, Index Advisor, Role/User Management, Data Generator, ERD, Lookup. - Trim deps: drop @xyflow/react, dagre, @types/dagre; cut tokio features to rt-multi-thread/sync/time/net/macros. - Remove unused TuskError variants and dead helpers (topological_sort, invalidate_schema_cache). Multi-DB (PostgreSQL + ClickHouse) - New src-tauri/src/db/ module: ChClient (HTTP-based, reuses reqwest), sql_guard (cross-flavor read-only whitelist with 8 tests). - ConnectionConfig gains db_flavor and secure fields with serde defaults for backwards-compatible connections.json. - All connection/query/schema/data commands dispatch by flavor; CH covers connect, execute_query, list_databases/schemas/tables/views/ columns/completion_schema, paginated table fetch. - Frontend: dbCapabilities matrix, ConnectionDialog engine selector with port auto-swap and HTTPS toggle, SqlEditor switches to StandardSQL dialect for CH, TableDataView surfaces CH connections as read-only. AI-first chat agent - New src/components/chat/ panel with composer, message rendering, collapsible tool-call/result blocks, top-level ErrorBoundary. - Backend agent loop in commands/chat.rs with strict-JSON tool protocol. Nine tools: list_databases, list_tables, get_columns, switch_database, run_query, remember, save_query, find_queries, final. Forgiving parser accepts both flat and nested-input shapes. - Compressed history: only the last 4 run_query results carry sample rows (≤10, cells truncated to 200 chars) into LLM context; older results marked omitted. - System prompt uses lite OVERVIEW (DB list + active-DB tables only) instead of full DDL — schema details are loaded on demand via get_columns. CH OVERVIEW shows cross-DB tables since CH allows db.table queries. Cross-session memory (F1) - Per-connection markdown file at app_data_dir/memory/<connection_id>.md, 16KB cap with oldest-block eviction. Agent appends via remember() tool; the file is injected into LEARNED NOTES section of every system prompt. - New Memory sidebar tab with editable textarea, badge for note count, empty-state with template. Edits picked up on the next agent turn. Saved-query reuse (F2) - Tools save_query and find_queries scoped to current connection. save_query attaches a UUID + timestamp; find_queries returns top 10 matches with SQL preview ≤500 chars. - Storage shared with the sidebar Saved panel. Inline result actions (F3) - run_query result block in chat gets Open-full (90vw × 80vh modal with full ResultsTable, no row cap) and Export (reuses ExportDialog for CSV/JSON via existing exportCsv/exportJson commands). Verification - cargo check clean, zero warnings. - cargo test --lib: 50 pass (20 chat parser + 4 memory + 8 sql_guard + 6 clean_sql + 12 escape_ident). - npx tsc --noEmit clean. - npx vitest run: 20 pass.
169 lines
5.6 KiB
Rust
169 lines
5.6 KiB
Rust
use crate::error::{TuskError, TuskResult};
|
|
use crate::models::query_result::QueryResult;
|
|
use serde::Deserialize;
|
|
use serde_json::{Map, Value};
|
|
use std::sync::LazyLock;
|
|
use std::time::{Duration, Instant};
|
|
|
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
|
|
|
|
fn http_client() -> &'static reqwest::Client {
|
|
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
|
reqwest::Client::builder()
|
|
.connect_timeout(Duration::from_secs(5))
|
|
.timeout(DEFAULT_TIMEOUT)
|
|
.build()
|
|
.unwrap_or_default()
|
|
});
|
|
&CLIENT
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ChClient {
|
|
pub base_url: String,
|
|
pub user: String,
|
|
pub password: String,
|
|
pub database: String,
|
|
}
|
|
|
|
impl ChClient {
|
|
pub fn new(host: &str, port: u16, secure: bool, user: &str, password: &str, database: &str) -> Self {
|
|
let scheme = if secure { "https" } else { "http" };
|
|
let base_url = format!("{}://{}:{}", scheme, host, port);
|
|
Self {
|
|
base_url,
|
|
user: user.to_string(),
|
|
password: password.to_string(),
|
|
database: database.to_string(),
|
|
}
|
|
}
|
|
|
|
fn endpoint(&self, database: Option<&str>, format: Option<&str>, read_only: bool) -> String {
|
|
let db = database.unwrap_or(&self.database);
|
|
let mut params = vec![
|
|
format!("database={}", urlencode(db)),
|
|
format!("user={}", urlencode(&self.user)),
|
|
];
|
|
if !self.password.is_empty() {
|
|
params.push(format!("password={}", urlencode(&self.password)));
|
|
}
|
|
if let Some(fmt) = format {
|
|
params.push(format!("default_format={}", urlencode(fmt)));
|
|
}
|
|
if read_only {
|
|
params.push("readonly=1".to_string());
|
|
}
|
|
format!("{}/?{}", self.base_url, params.join("&"))
|
|
}
|
|
|
|
/// Execute SQL and return raw response body.
|
|
pub async fn execute_raw(&self, sql: &str, format: Option<&str>, read_only: bool) -> TuskResult<String> {
|
|
let url = self.endpoint(None, format, read_only);
|
|
let resp = http_client()
|
|
.post(&url)
|
|
.body(sql.to_string())
|
|
.send()
|
|
.await
|
|
.map_err(|e| TuskError::Custom(format!("ClickHouse request failed: {}", e)))?;
|
|
let status = resp.status();
|
|
let body = resp
|
|
.text()
|
|
.await
|
|
.map_err(|e| TuskError::Custom(format!("Failed to read ClickHouse response: {}", e)))?;
|
|
if !status.is_success() {
|
|
return Err(TuskError::Custom(format!(
|
|
"ClickHouse error ({}): {}",
|
|
status,
|
|
body.trim()
|
|
)));
|
|
}
|
|
Ok(body)
|
|
}
|
|
|
|
/// Test connection by running `SELECT 1` and return the server version.
|
|
pub async fn ping(&self) -> TuskResult<String> {
|
|
// Use raw FORMAT TabSeparated to fetch version
|
|
let body = self.execute_raw("SELECT version()", Some("TabSeparated"), false).await?;
|
|
Ok(body.trim().to_string())
|
|
}
|
|
|
|
/// Execute SQL and parse rows via JSONCompact to preserve column metadata + types.
|
|
pub async fn execute_query(&self, sql: &str, read_only: bool) -> TuskResult<QueryResult> {
|
|
let start = Instant::now();
|
|
let body = self.execute_raw(sql, Some("JSONCompact"), read_only).await?;
|
|
let execution_time_ms = start.elapsed().as_millis() as u64;
|
|
|
|
// Empty body for statements without result set (DDL etc.) — return zero rows
|
|
if body.trim().is_empty() {
|
|
return Ok(QueryResult {
|
|
columns: vec![],
|
|
types: vec![],
|
|
rows: vec![],
|
|
row_count: 0,
|
|
execution_time_ms,
|
|
});
|
|
}
|
|
|
|
let parsed: ChJsonCompactResponse = serde_json::from_str(&body).map_err(|e| {
|
|
TuskError::Custom(format!(
|
|
"Failed to parse ClickHouse JSONCompact response: {} (body head: {})",
|
|
e,
|
|
body.chars().take(200).collect::<String>()
|
|
))
|
|
})?;
|
|
|
|
let columns: Vec<String> = parsed.meta.iter().map(|m| m.name.clone()).collect();
|
|
let types: Vec<String> = parsed.meta.iter().map(|m| m.r#type.clone()).collect();
|
|
let row_count = parsed.data.len();
|
|
|
|
Ok(QueryResult {
|
|
columns,
|
|
types,
|
|
rows: parsed.data,
|
|
row_count,
|
|
execution_time_ms,
|
|
})
|
|
}
|
|
|
|
/// Execute SQL expecting result rows as objects (for schema introspection helpers).
|
|
pub async fn fetch_objects(&self, sql: &str) -> TuskResult<Vec<Map<String, Value>>> {
|
|
let body = self.execute_raw(sql, Some("JSONEachRow"), false).await?;
|
|
let mut out = Vec::new();
|
|
for line in body.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
let value: Value = serde_json::from_str(line).map_err(|e| {
|
|
TuskError::Custom(format!("Failed to parse JSONEachRow line: {}", e))
|
|
})?;
|
|
if let Value::Object(obj) = value {
|
|
out.push(obj);
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ChJsonCompactResponse {
|
|
meta: Vec<ChMetaEntry>,
|
|
data: Vec<Vec<Value>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ChMetaEntry {
|
|
name: String,
|
|
r#type: String,
|
|
}
|
|
|
|
fn urlencode(s: &str) -> String {
|
|
s.chars()
|
|
.map(|c| match c {
|
|
':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' | '*'
|
|
| '+' | ',' | ';' | '=' | '%' | ' ' => format!("%{:02X}", c as u8),
|
|
_ => c.to_string(),
|
|
})
|
|
.collect()
|
|
}
|