feat: rescope to AI-first DB harness with multi-DB chat agent
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.
This commit is contained in:
168
src-tauri/src/db/clickhouse.rs
Normal file
168
src-tauri/src/db/clickhouse.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user