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:
2026-05-06 19:30:44 +03:00
parent 652937f7f5
commit 4f7afc17f4
89 changed files with 4151 additions and 10756 deletions

View File

@@ -1,5 +1,4 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type {
ConnectionConfig,
ConnectResult,
@@ -12,38 +11,13 @@ import type {
ConstraintInfo,
IndexInfo,
TriggerInfo,
ErdData,
HistoryEntry,
SavedQuery,
SessionInfo,
DatabaseInfo,
CreateDatabaseParams,
RoleInfo,
CreateRoleParams,
AlterRoleParams,
TablePrivilege,
GrantRevokeParams,
RoleMembershipParams,
AiSettings,
OllamaModel,
EntityLookupResult,
LookupProgress,
DockerStatus,
CloneToDockerParams,
CloneProgress,
CloneResult,
TuskContainer,
AppSettings,
McpStatus,
ValidationRule,
GenerateDataParams,
GeneratedDataPreview,
DataGenProgress,
IndexAdvisorReport,
SnapshotMetadata,
CreateSnapshotParams,
RestoreSnapshotParams,
SnapshotProgress,
ChatMessage,
} from "@/types";
// Connections
@@ -139,9 +113,6 @@ export const getTableTriggers = (
table: string
) => invoke<TriggerInfo[]>("get_table_triggers", { connectionId, schema, table });
export const getSchemaErd = (connectionId: string, schema: string) =>
invoke<ErdData>("get_schema_erd", { connectionId, schema });
// Data
export const getTableData = (params: {
connectionId: string;
@@ -229,47 +200,6 @@ export const exportJson = (
rows: unknown[][]
) => invoke<void>("export_json", { path, columns, rows });
// Management
export const getDatabaseInfo = (connectionId: string) =>
invoke<DatabaseInfo[]>("get_database_info", { connectionId });
export const createDatabase = (connectionId: string, params: CreateDatabaseParams) =>
invoke<void>("create_database", { connectionId, params });
export const dropDatabase = (connectionId: string, name: string) =>
invoke<void>("drop_database", { connectionId, name });
export const listRoles = (connectionId: string) =>
invoke<RoleInfo[]>("list_roles", { connectionId });
export const createRole = (connectionId: string, params: CreateRoleParams) =>
invoke<void>("create_role", { connectionId, params });
export const alterRole = (connectionId: string, params: AlterRoleParams) =>
invoke<void>("alter_role", { connectionId, params });
export const dropRole = (connectionId: string, name: string) =>
invoke<void>("drop_role", { connectionId, name });
export const getTablePrivileges = (connectionId: string, schema: string, table: string) =>
invoke<TablePrivilege[]>("get_table_privileges", { connectionId, schema, table });
export const grantRevoke = (connectionId: string, params: GrantRevokeParams) =>
invoke<void>("grant_revoke", { connectionId, params });
export const manageRoleMembership = (connectionId: string, params: RoleMembershipParams) =>
invoke<void>("manage_role_membership", { connectionId, params });
// Sessions
export const listSessions = (connectionId: string) =>
invoke<SessionInfo[]>("list_sessions", { connectionId });
export const cancelQuery = (connectionId: string, pid: number) =>
invoke<boolean>("cancel_query", { connectionId, pid });
export const terminateBackend = (connectionId: string, pid: number) =>
invoke<boolean>("terminate_backend", { connectionId, pid });
// AI
export const getAiSettings = () =>
invoke<AiSettings>("get_ai_settings");
@@ -289,50 +219,15 @@ export const explainSql = (connectionId: string, sql: string) =>
export const fixSqlError = (connectionId: string, sql: string, errorMessage: string) =>
invoke<string>("fix_sql_error", { connectionId, sql, errorMessage });
// Entity Lookup
export const entityLookup = (
config: ConnectionConfig,
columnName: string,
value: string,
lookupId: string,
databases?: string[]
) =>
invoke<EntityLookupResult>("entity_lookup", {
config,
columnName,
value,
lookupId,
databases,
});
export const chatSend = (connectionId: string, messages: ChatMessage[]) =>
invoke<ChatMessage[]>("chat_send", { connectionId, messages });
export const onLookupProgress = (
callback: (p: LookupProgress) => void
): Promise<UnlistenFn> =>
listen<LookupProgress>("lookup-progress", (e) => callback(e.payload));
// Memory (per-connection markdown notes for the chat agent)
export const getMemory = (connectionId: string) =>
invoke<string>("get_memory", { connectionId });
// Docker
export const checkDocker = () =>
invoke<DockerStatus>("check_docker");
export const listTuskContainers = () =>
invoke<TuskContainer[]>("list_tusk_containers");
export const cloneToDocker = (params: CloneToDockerParams, cloneId: string) =>
invoke<CloneResult>("clone_to_docker", { params, cloneId });
export const startContainer = (name: string) =>
invoke<void>("start_container", { name });
export const stopContainer = (name: string) =>
invoke<void>("stop_container", { name });
export const removeContainer = (name: string) =>
invoke<void>("remove_container", { name });
export const onCloneProgress = (
callback: (p: CloneProgress) => void
): Promise<UnlistenFn> =>
listen<CloneProgress>("clone-progress", (e) => callback(e.payload));
export const saveMemory = (connectionId: string, content: string) =>
invoke<void>("save_memory", { connectionId, content });
// App Settings
export const getAppSettings = () =>
@@ -343,50 +238,3 @@ export const saveAppSettings = (settings: AppSettings) =>
export const getMcpStatus = () =>
invoke<McpStatus>("get_mcp_status");
// Validation (Wave 1)
export const generateValidationSql = (connectionId: string, ruleDescription: string) =>
invoke<string>("generate_validation_sql", { connectionId, ruleDescription });
export const runValidationRule = (connectionId: string, sql: string, sampleLimit?: number) =>
invoke<ValidationRule>("run_validation_rule", { connectionId, sql, sampleLimit });
export const suggestValidationRules = (connectionId: string) =>
invoke<string[]>("suggest_validation_rules", { connectionId });
// Data Generator (Wave 2)
export const generateTestDataPreview = (params: GenerateDataParams, genId: string) =>
invoke<GeneratedDataPreview>("generate_test_data_preview", { params, genId });
export const insertGeneratedData = (connectionId: string, preview: GeneratedDataPreview) =>
invoke<number>("insert_generated_data", { connectionId, preview });
export const onDataGenProgress = (
callback: (p: DataGenProgress) => void
): Promise<UnlistenFn> =>
listen<DataGenProgress>("datagen-progress", (e) => callback(e.payload));
// Index Advisor (Wave 3A)
export const getIndexAdvisorReport = (connectionId: string) =>
invoke<IndexAdvisorReport>("get_index_advisor_report", { connectionId });
export const applyIndexRecommendation = (connectionId: string, ddl: string) =>
invoke<void>("apply_index_recommendation", { connectionId, ddl });
// Snapshots (Wave 3B)
export const createSnapshot = (params: CreateSnapshotParams, snapshotId: string, filePath: string) =>
invoke<SnapshotMetadata>("create_snapshot", { params, snapshotId, filePath });
export const restoreSnapshot = (params: RestoreSnapshotParams, snapshotId: string) =>
invoke<number>("restore_snapshot", { params, snapshotId });
export const listSnapshots = () =>
invoke<SnapshotMetadata[]>("list_snapshots");
export const readSnapshotMetadata = (filePath: string) =>
invoke<SnapshotMetadata>("read_snapshot_metadata", { filePath });
export const onSnapshotProgress = (
callback: (p: SnapshotProgress) => void
): Promise<UnlistenFn> =>
listen<SnapshotProgress>("snapshot-progress", (e) => callback(e.payload));