feat(ai): add vector DB assistant tools

This commit is contained in:
lexmin0412 2026-06-29 11:46:41 +08:00 committed by GitHub
parent 3c7c1924ba
commit bc409cffc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 559 additions and 17 deletions

View File

@ -17,7 +17,7 @@ import { connectionIconType } from "@/lib/connectionPresentation";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useQueryStore } from "@/stores/queryStore";
import { useToast } from "@/composables/useToast";
import { buildAiContext, runAgentStream, type AiAction } from "@/lib/ai";
import { buildAiContext, runAgentStream, isVectorDbType, type AiAction } from "@/lib/ai";
import { formatAiModelOption } from "@/lib/aiModelPresentation";
import type { AgentEvent } from "@/lib/tauri";
import { buildAiAgentPlan } from "@/lib/aiAgentPlan";
@ -287,6 +287,11 @@ const actionMenuItems = computed(() =>
);
const aiCodeAppearance = computed(() => (isDark.value ? "dark" : "light"));
const showActionButtons = computed(() => {
if (!props.connection) return true;
return !isVectorDbType(props.connection.db_type);
});
const { databaseOptions: allDbOptions, loadDatabaseOptions } = useDatabaseOptions();
const dbOptions = computed(() => {
@ -1312,6 +1317,7 @@ async function openExternalUrl(url: string) {
item-class="text-xs px-2"
/>
<LightDropdown
v-if="showActionButtons"
:model-value="activeAction"
:items="actionMenuItems"
content-class="w-max min-w-0"

View File

@ -10,6 +10,28 @@ import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import type { AgentEvent } from "@/lib/tauri";
const VECTOR_DB_TYPES: ReadonlySet<DatabaseType> = new Set([
"qdrant",
"milvus",
"weaviate",
"chromadb",
// If modifying this, also update is_vector_db() in crates/dbx-core/src/agent_tools.rs.
]);
export function isVectorDbType(dbType: DatabaseType): boolean {
return VECTOR_DB_TYPES.has(dbType);
}
function dbLabel(dbType: DatabaseType): string {
const labels: Partial<Record<DatabaseType, string>> = {
qdrant: "Qdrant",
milvus: "Milvus",
weaviate: "Weaviate",
chromadb: "ChromaDB",
};
return labels[dbType] || dbType;
}
export type AiAction = "generate" | "explain" | "optimize" | "fix" | "convert" | "sampleData";
export type AiAssistantMode = "ask" | "agent";
@ -50,10 +72,8 @@ export interface AiRequestInput {
function buildAgentRequest(input: AiRequestInput, history?: api.AiMessage[]): { messages: api.AiMessage[]; systemPrompt: string; taskContract: api.AiTaskContract; maxTokens: number; temperature: number } {
const isZh = isChineseLocale(currentLocale());
const skill = aiSkillForAction(input.action);
const systemPrompt = buildSystemPrompt(input.action, input.context, input.mode);
const instruction = isZh ? skill.userInstruction.zh : skill.userInstruction.en;
const userPrompt = [`Action: ${input.action}`, instruction, "", "User request:", input.instruction.trim() || "(No extra instruction provided.)"].join("\n");
const userPrompt = buildUserPrompt(input.action, input.context, input.instruction, isZh);
const taskContract: api.AiTaskContract = {
action: input.action,
mode: input.mode || "ask",
@ -124,6 +144,17 @@ export async function runAgentStream(input: AiRequestInput, history: api.AiMessa
);
}
export function buildUserPrompt(action: AiAction, context: AiContext, instruction: string, isZh: boolean): string {
const userRequest = instruction.trim() || (isZh ? "(无额外说明)" : "(No extra instruction provided.)");
if (isVectorDbType(context.databaseType)) {
// Vector databases: skip SQL action instructions, only send the user's request
return userRequest;
}
const skill = aiSkillForAction(action);
const skillInstruction = isZh ? skill.userInstruction.zh : skill.userInstruction.en;
return [`Action: ${action}`, skillInstruction, "", "User request:", userRequest].join("\n");
}
function actionParams(action: AiAction): { maxTokens: number; temperature: number } {
switch (action) {
case "explain":
@ -142,6 +173,9 @@ export function extractSql(text: string): string {
}
export function buildSystemPrompt(action: AiAction, context: AiContext, mode: AiAssistantMode = "ask"): string {
if (isVectorDbType(context.databaseType)) {
return buildVectorSystemPrompt(context, mode);
}
const schema = formatSchema(context);
const resultPreview = context.lastResultPreview ? `\nLast result preview:\n${context.lastResultPreview}\n` : "";
const lastError = context.lastError ? `\nLast error:\n${context.lastError}\n` : "";
@ -207,6 +241,50 @@ function buildBasePromptLines(isZh: boolean): string[] {
];
}
function buildVectorSystemPrompt(context: AiContext, mode: AiAssistantMode): string {
const isZh = isChineseLocale(currentLocale());
const schema = formatSchema(context);
const resultPreview = context.lastResultPreview ? `\nLast result preview:\n${context.lastResultPreview}\n` : "";
const lastError = context.lastError ? `\nLast error:\n${context.lastError}\n` : "";
const lines: string[] = [
isZh ? `你是 DBX 内置的向量数据库助手。当前连接的是 ${dbLabel(context.databaseType)} 数据库。用中文回复。` : `You are DBX's vector database assistant. Connected to ${dbLabel(context.databaseType)}. Reply in English.`,
isZh ? "数据存储在集合collections每条记录包含唯一标识及可选的元数据负载payload/metadata。" : "Data is stored in collections. Each record has a unique identifier and optional metadata payload.",
...buildVectorModePromptLines(context, mode, isZh),
"",
`Database type: ${context.databaseType}`,
`Connection: ${context.connectionName}`,
`Database: ${context.database}`,
schemaCoverageLine(context, isZh),
"",
`Current collection:\n${context.currentSql.trim() || "(none)"}`,
lastError,
resultPreview,
"",
`Schema:\n${schema}`,
];
if (context.schemaScope === "focused_table") {
lines.push(
isZh
? "Schema 上下文只覆盖当前打开的集合;数据库中可能还有其他集合。用户询问当前有哪些集合或提到上下文中不存在的集合时,不要直接断言不存在,先用 list_collections 工具确认。"
: "Schema context covers only the currently opened collection; the database may contain other collections. When the user asks what collections exist or mentions a collection absent from context, do not conclude it is missing; use list_collections to verify first.",
);
}
return lines.filter(Boolean).join("\n");
}
function buildVectorModePromptLines(context: AiContext, mode: AiAssistantMode, isZh: boolean): string[] {
if (mode === "agent") {
return [isZh ? "你处于 Agent 模式。你有以下工具可用list_collections、browse_collection。" : "You are in Agent mode. You have the following tools available: list_collections, browse_collection."];
}
return [
isZh
? `你处于 Ask 模式。你只能使用 list_collections 确认集合清单;不要浏览集合数据。${dbLabel(context.databaseType)} 的查询格式为 REST APIMETHOD /path + JSON body具体格式因数据库类型而异。只生成查询请求文本和说明不要暗示已经执行。`
: `You are in Ask mode. You may only use list_collections to inspect collection names; do not browse collection data. ${dbLabel(context.databaseType)} uses a REST API query format (METHOD /path + JSON body) that varies by database type. Generate query strings and explanations only; do not imply execution.`,
];
}
function buildModePromptLines(mode: AiAssistantMode, isZh: boolean): string[] {
if (mode === "agent") {
return [
@ -226,7 +304,10 @@ function buildModePromptLines(mode: AiAssistantMode, isZh: boolean): string[] {
function schemaCoverageLine(context: AiContext, isZh: boolean): string {
if (context.schemaScope === "focused_table") {
return isZh ? "Schema context scope: focused table only; not a complete database table list." : "Schema context scope: focused table only; not a complete database table list.";
if (isVectorDbType(context.databaseType)) {
return isZh ? "Schema 上下文只覆盖当前打开的集合,不是完整的集合列表。" : "Schema context scope: focused collection only; not a complete collection list.";
}
return "Schema context scope: focused table only; not a complete database table list.";
}
return context.truncated ? "Schema context is truncated." : "Schema context is complete for the loaded database scope.";
}
@ -279,6 +360,7 @@ export async function buildAiContext(tab: QueryTab, connection: ConnectionConfig
const tableKeys = new Set<string>();
let truncated = false;
let schemaScope: AiContext["schemaScope"] = "database";
let currentCollectionName: string | undefined;
if (tab.tableMeta) {
schemaScope = "focused_table";
@ -308,7 +390,43 @@ export async function buildAiContext(tab: QueryTab, connection: ConnectionConfig
tables.push(entry);
}
if (!tab.tableMeta && !["redis", "mongodb"].includes(connection.db_type)) {
// Vector databases: load collections instead of SQL tables
if (isVectorDbType(databaseType)) {
try {
const collections = await api.vectorListCollections(tab.connectionId);
// Find the currently opened collection (tab.sql is UUID for ChromaDB, name for others)
const currentCollection = collections.find((c) => c.id === tab.sql || c.name === tab.sql);
if (currentCollection) {
schemaScope = "focused_table";
currentCollectionName = currentCollection.name;
tables.push({
name: currentCollection.name,
tableType: "COLLECTION",
comment: currentCollection.dimension ? `${currentCollection.dimension}d vector` : undefined,
columns: [],
});
tableKeys.add(aiTableMentionKey(undefined, currentCollection.name));
}
for (const col of collections.slice(0, maxTables)) {
const key = aiTableMentionKey(undefined, col.name);
if (tableKeys.has(key)) continue;
tables.push({
name: col.name,
tableType: "COLLECTION",
comment: col.dimension ? `${col.dimension}d vector` : undefined,
columns: [],
});
tableKeys.add(key);
}
if (collections.length > maxTables) truncated = true;
} catch {
truncated = true;
}
}
if (!tab.tableMeta && !["redis", "mongodb"].includes(connection.db_type) && !isVectorDbType(databaseType)) {
try {
const schemas = await loadCandidateSchemas(tab, connection);
for (const schema of schemas) {
@ -355,7 +473,7 @@ export async function buildAiContext(tab: QueryTab, connection: ConnectionConfig
connectionName: connection.name,
databaseType,
database: tab.database,
currentSql: tab.sql,
currentSql: currentCollectionName ?? tab.sql,
lastError: extractLastError(tab.result),
lastResultPreview: formatResultPreview(tab.result),
tables,

View File

@ -129,7 +129,11 @@ pub async fn run_agent_loop(
)
.await;
}
let tools = if is_agent_mode { agent_tools::all_tools(agent_ctx.db_type) } else { agent_tools::read_only_tools() };
let tools = if is_agent_mode {
agent_tools::all_tools(agent_ctx.db_type)
} else {
agent_tools::read_only_tools(agent_ctx.db_type)
};
let task_contract = task_contract.cloned();
let mut conversation_messages: Vec<AiMessage> = messages.to_vec();
let mut final_text = String::new();

View File

@ -4,6 +4,7 @@ use serde_json::json;
use crate::agent_events::{ToolCall, ToolDefinition, ToolResult};
use crate::connection::AppState;
use crate::db::vector_driver;
use crate::models::connection::DatabaseType;
use crate::query::QueryExecutionOptions;
use crate::query_execution_sql::{build_explain_sql, supports_explain_plan, supports_sql_query, ExplainSqlOptions};
@ -19,18 +20,35 @@ const EXECUTE_QUERY_LIMIT: usize = 50;
/// Maximum number of rows returned by get_sample_data tool.
const SAMPLE_DATA_LIMIT: usize = 20;
/// Maximum number of rows returned by browse_collection tool.
const BROWSE_COLLECTION_LIMIT: usize = 20;
/// Absolute maximum rows any query tool may request.
const MAX_ALLOWED_ROWS: usize = 100;
/// Get read-only tool definitions (list_tables + get_columns).
pub fn read_only_tools() -> Vec<ToolDefinition> {
vec![list_tables_tool(), get_columns_tool()]
/// Returns true for vector database types (Qdrant, Milvus, Weaviate, ChromaDb).
/// If modifying this, also update VECTOR_DB_TYPES in apps/desktop/src/lib/ai.ts.
pub fn is_vector_db(db_type: DatabaseType) -> bool {
matches!(db_type, DatabaseType::Qdrant | DatabaseType::Milvus | DatabaseType::Weaviate | DatabaseType::ChromaDb)
}
/// Get read-only tool definitions for the given database type.
/// Returns vector tools for vector DBs, SQL tools otherwise.
pub fn read_only_tools(db_type: DatabaseType) -> Vec<ToolDefinition> {
if is_vector_db(db_type) {
vec![list_collections_tool()]
} else {
vec![list_tables_tool(), get_columns_tool()]
}
}
/// Get all available tool definitions for the given database type.
/// Includes read-only tools plus execute_query, get_sample_data, and
/// explain_query for database types that support them.
pub fn all_tools(db_type: DatabaseType) -> Vec<ToolDefinition> {
if is_vector_db(db_type) {
return vec![list_collections_tool(), browse_collection_tool()];
}
let mut tools = vec![list_tables_tool(), get_columns_tool()];
if supports_sql_query(db_type) {
tools.push(execute_query_tool());
@ -167,6 +185,45 @@ fn explain_query_tool() -> ToolDefinition {
}
}
/// list_collections tool definition (vector databases).
fn list_collections_tool() -> ToolDefinition {
ToolDefinition {
name: "list_collections",
description: "List all collections in the current vector database. Returns collection names and dimensions.",
parameters: json!({
"type": "object",
"properties": {},
"required": []
}),
read_only: true,
parallel_ok: true,
}
}
/// browse_collection tool definition (vector databases).
fn browse_collection_tool() -> ToolDefinition {
ToolDefinition {
name: "browse_collection",
description: "Browse documents in a collection. Returns up to 20 items with payload/metadata (vectors excluded for compactness). For ChromaDB, use the collection id (UUID from list_collections) instead of the collection name.",
parameters: json!({
"type": "object",
"properties": {
"collection": {
"type": "string",
"description": "Collection name"
},
"limit": {
"type": "number",
"description": "Max items to return (default 20, max 100)"
}
},
"required": ["collection"]
}),
read_only: true,
parallel_ok: true,
}
}
/// Execute a tool call and return the result.
pub async fn execute_tool(
tool_call: &ToolCall,
@ -180,6 +237,8 @@ pub async fn execute_tool(
"get_columns" => execute_get_columns(tool_call, state, connection_id, database, db_type).await,
"execute_query" => execute_execute_query(tool_call, state, connection_id, database, db_type).await,
"get_sample_data" => execute_get_sample_data(tool_call, state, connection_id, database, db_type).await,
"list_collections" => execute_list_collections(tool_call, state, connection_id, database, db_type).await,
"browse_collection" => execute_browse_collection(tool_call, state, connection_id, database, db_type).await,
"explain_query" => {
let (text_result, explain_data) =
execute_explain_query(tool_call, state, connection_id, database, db_type).await;
@ -552,3 +611,233 @@ async fn execute_explain_query(
(Ok(text), explain_data)
}
/// Execute list_collections tool (vector databases).
async fn execute_list_collections(
_tool_call: &ToolCall,
state: &Arc<AppState>,
connection_id: &str,
database: &str,
_db_type: &DatabaseType,
) -> Result<String, String> {
let collections = crate::schema::list_vector_collections_core(state, connection_id, database)
.await
.map_err(|e| format!("Failed to list collections: {e}"))?;
if collections.is_empty() {
return Ok("No collections found.".to_string());
}
let mut lines: Vec<String> = collections
.iter()
.map(|c| {
let mut line = format!("- {} (COLLECTION)", c.name);
if let Some(dim) = c.dimension {
line.push_str(&format!(" -- {}d", dim));
}
line.push_str(&format!(" [id: {}]", c.id));
line
})
.collect();
if lines.len() > LIST_TABLES_LIMIT {
lines.truncate(LIST_TABLES_LIMIT);
lines.push(format!("... (showing {LIST_TABLES_LIMIT} of {} collections)", collections.len()));
}
Ok(lines.join("\n"))
}
/// Execute browse_collection tool (vector databases).
/// Generates a database-specific REST query and executes it.
async fn execute_browse_collection(
tool_call: &ToolCall,
state: &Arc<AppState>,
connection_id: &str,
database: &str,
db_type: &DatabaseType,
) -> Result<String, String> {
let collection = tool_call
.arguments
.get("collection")
.and_then(|v| v.as_str())
.ok_or("Missing required parameter: collection")?
.trim();
if collection.is_empty() {
return Err("Collection name cannot be empty".to_string());
}
let limit = tool_call
.arguments
.get("limit")
.and_then(|v| v.as_u64())
.map(|l| (l as usize).min(MAX_ALLOWED_ROWS))
.unwrap_or(BROWSE_COLLECTION_LIMIT);
// ChromaDB requires UUID in URL path, not collection name.
// If the collection param is already a UUID (from list_collections output), use it directly.
let collection_id = if *db_type == DatabaseType::ChromaDb && !is_uuid(collection) {
resolve_chroma_collection_uuid(state, connection_id, database, collection).await?
} else {
collection.to_string()
};
let query = build_browse_query(db_type, &collection_id, database, limit)?;
let options = QueryExecutionOptions { max_rows: Some(limit), timeout_secs: Some(30), ..Default::default() };
let result =
crate::query::execute_sql_statement_with_options(state, connection_id, database, &query, None, None, options)
.await?;
format_query_result_as_text(&result, limit)
}
/// Build a browse query for the given vector database type.
/// Intentionally omits offset/pagination — Agent browse only fetches the first N items.
fn build_browse_query(
db_type: &DatabaseType,
collection: &str,
database: &str,
limit: usize,
) -> Result<String, String> {
let collection = collection.trim();
if collection.is_empty() {
return Err("Collection name cannot be empty".to_string());
}
let limit = limit.max(1) as u64;
match db_type {
DatabaseType::Qdrant => Ok(format!(
"POST /collections/{}/points/scroll\n{}",
vector_driver::path_segment(collection),
serde_json::json!({ "limit": limit, "with_payload": true, "with_vector": false })
)),
// Milvus v2 omitting outputFields defaults to returning only scalar fields (no vectors).
DatabaseType::Milvus => Ok(format!(
"POST /v2/vectordb/entities/query\n{}",
serde_json::json!({
"dbName": if database.is_empty() { "default" } else { database },
"collectionName": collection,
"filter": "", "limit": limit
})
)),
DatabaseType::Weaviate => {
Ok(format!("GET /v1/objects?class={}&limit={}", vector_driver::query_value(collection), limit))
}
// TODO: ChromaDB Cloud 支持自定义租户和数据库,当前只实现了本地部署
// (固定 default_tenant / default_database后续支持云服务时需改为可配置。
DatabaseType::ChromaDb => Ok(format!(
"POST /api/v2/tenants/default_tenant/databases/default_database/collections/{}/get\n{}",
collection,
serde_json::json!({ "limit": limit, "include": ["documents", "metadatas"] })
)),
_ => Err(format!("Unsupported database type: {:?}", db_type)),
}
}
/// Check if a string looks like a UUID (simple check — 36 chars with 4 hyphens).
fn is_uuid(s: &str) -> bool {
s.len() == 36
&& s.chars().filter(|&c| c == '-').count() == 4
&& s.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
}
/// Resolve a ChromaDB collection name to its UUID by listing all collections.
async fn resolve_chroma_collection_uuid(
state: &Arc<AppState>,
connection_id: &str,
database: &str,
name: &str,
) -> Result<String, String> {
let collections = crate::schema::list_vector_collections_core(state, connection_id, database).await?;
collections
.into_iter()
.find(|c| c.name == name)
.map(|c| c.id)
.ok_or_else(|| format!("Collection '{name}' not found"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vector_read_only_tools_do_not_include_collection_browsing() {
let tools = read_only_tools(DatabaseType::Qdrant);
let names: Vec<&str> = tools.iter().map(|tool| tool.name).collect();
assert_eq!(names, vec!["list_collections"]);
}
#[test]
fn vector_agent_tools_include_collection_browsing() {
let tools = all_tools(DatabaseType::Qdrant);
let names: Vec<&str> = tools.iter().map(|tool| tool.name).collect();
assert_eq!(names, vec!["list_collections", "browse_collection"]);
}
#[test]
fn build_browse_query_qdrant() {
let q = build_browse_query(&DatabaseType::Qdrant, "articles", "", 10).unwrap();
assert!(q.starts_with("POST /collections/articles/points/scroll"));
assert!(q.contains("\"limit\":10"));
assert!(q.contains("\"with_payload\":true"));
}
#[test]
fn build_browse_query_qdrant_encodes_url_chars() {
let q = build_browse_query(&DatabaseType::Qdrant, "my collection", "", 10).unwrap();
assert!(q.starts_with("POST /collections/my%20collection/points/scroll"));
}
#[test]
fn build_browse_query_milvus() {
let q = build_browse_query(&DatabaseType::Milvus, "articles", "custom_db", 20).unwrap();
assert!(q.starts_with("POST /v2/vectordb/entities/query"));
assert!(q.contains("\"dbName\":\"custom_db\""));
assert!(q.contains("\"collectionName\":\"articles\""));
assert!(q.contains("\"limit\":20"));
assert!(!q.contains("outputFields"));
}
#[test]
fn build_browse_query_milvus_default_db() {
let q = build_browse_query(&DatabaseType::Milvus, "articles", "", 10).unwrap();
assert!(q.contains("\"dbName\":\"default\""));
}
#[test]
fn build_browse_query_weaviate() {
let q = build_browse_query(&DatabaseType::Weaviate, "Articles", "", 5).unwrap();
assert_eq!(q, "GET /v1/objects?class=Articles&limit=5");
}
#[test]
fn build_browse_query_weaviate_encodes_query_param() {
let q = build_browse_query(&DatabaseType::Weaviate, "A&B", "", 5).unwrap();
assert!(q.contains("class=A%26B"));
}
#[test]
fn build_browse_query_chromadb() {
let q = build_browse_query(&DatabaseType::ChromaDb, "uuid-123", "", 15).unwrap();
assert!(
q.starts_with("POST /api/v2/tenants/default_tenant/databases/default_database/collections/uuid-123/get")
);
assert!(q.contains("\"limit\":15"));
}
#[test]
fn build_browse_query_rejects_empty_collection() {
let result = build_browse_query(&DatabaseType::Qdrant, " ", "", 10);
assert!(result.is_err());
}
#[test]
fn build_browse_query_rejects_unsupported_type() {
let result = build_browse_query(&DatabaseType::Postgres, "articles", "", 10);
assert!(result.is_err());
}
}

View File

@ -152,9 +152,17 @@ pub async fn test_connection(client: &VectorClient, timeout: Duration) -> Result
}
pub async fn list_collections(client: &VectorClient) -> Result<Vec<CollectionInfo>, String> {
list_collections_with_db(client, "").await
}
/// List collections, passing an optional database name (used by Milvus).
pub(crate) async fn list_collections_with_db(
client: &VectorClient,
database: &str,
) -> Result<Vec<CollectionInfo>, String> {
match client.kind {
VectorDbKind::Qdrant => list_qdrant_collections(client).await,
VectorDbKind::Milvus => list_milvus_collections(client).await,
VectorDbKind::Milvus => list_milvus_collections(client, database).await,
VectorDbKind::Weaviate => list_weaviate_collections(client).await,
VectorDbKind::ChromaDb => list_chroma_collections(client).await,
}
@ -176,9 +184,10 @@ async fn list_qdrant_collections(client: &VectorClient) -> Result<Vec<Collection
Ok(infos)
}
async fn list_milvus_collections(client: &VectorClient) -> Result<Vec<CollectionInfo>, String> {
async fn list_milvus_collections(client: &VectorClient, database: &str) -> Result<Vec<CollectionInfo>, String> {
let db_name = if database.is_empty() { "default" } else { database };
let body = send_json(
client.post("/v2/vectordb/collections/list").json(&serde_json::json!({ "dbName": "default" })),
client.post("/v2/vectordb/collections/list").json(&serde_json::json!({ "dbName": db_name })),
"Milvus",
)
.await?;
@ -424,11 +433,11 @@ fn starts_with_http_method(input: &str) -> bool {
["GET ", "POST ", "PUT ", "DELETE "].iter().any(|prefix| input.to_ascii_uppercase().starts_with(prefix))
}
fn path_segment(value: &str) -> String {
pub(crate) fn path_segment(value: &str) -> String {
utf8_percent_encode(value, PATH_SEGMENT_ENCODE_SET).to_string()
}
fn query_value(value: &str) -> String {
pub(crate) fn query_value(value: &str) -> String {
utf8_percent_encode(value, QUERY_VALUE_ENCODE_SET).to_string()
}

View File

@ -816,6 +816,25 @@ pub async fn list_tables_core(
.await
}
/// List vector database collections, returning structured info (name, id, dimension).
/// Only works for PoolKind::VectorDb connections; returns an error for other types.
pub async fn list_vector_collections_core(
state: &AppState,
connection_id: &str,
database: &str,
) -> Result<Vec<db::vector_driver::CollectionInfo>, String> {
let pool_key =
state.get_or_create_pool(connection_id, if database.is_empty() { None } else { Some(database) }).await?;
let client = {
let connections = state.connections.read().await;
match connections.get(&pool_key) {
Some(PoolKind::VectorDb(client)) => client.clone(),
_ => return Err("Not a vector database connection".to_string()),
}
};
db::vector_driver::list_collections_with_db(&client, database).await
}
pub async fn get_table_comment_core(
state: &AppState,
connection_id: &str,

View File

@ -30,7 +30,7 @@ Object.defineProperty(globalThis, "localStorage", {
configurable: true,
});
const { buildSystemPrompt } = await import("../../apps/desktop/src/lib/ai.ts");
const { buildSystemPrompt, isVectorDbType, buildUserPrompt } = await import("../../apps/desktop/src/lib/ai.ts");
function context(overrides: Partial<AiContext> = {}): AiContext {
return {
@ -98,3 +98,100 @@ test("prompt enforces database dialect and single executable statement safety",
assert.match(prompt, /不要生成多语句 SQL/);
assert.match(prompt, /不要在同一个回答里混合 SELECT 和写操作/);
});
// Vector database tests
function vectorContext(overrides: Partial<AiContext> = {}): AiContext {
return {
connectionName: "my-qdrant",
databaseType: "qdrant",
database: "default",
currentSql: "articles",
tables: [
{
name: "articles",
tableType: "COLLECTION",
comment: "384d vector",
columns: [],
},
],
truncated: false,
...overrides,
};
}
test("isVectorDbType returns true for vector databases", () => {
assert.equal(isVectorDbType("qdrant"), true);
assert.equal(isVectorDbType("milvus"), true);
assert.equal(isVectorDbType("weaviate"), true);
assert.equal(isVectorDbType("chromadb"), true);
});
test("isVectorDbType returns false for SQL databases", () => {
assert.equal(isVectorDbType("mysql"), false);
assert.equal(isVectorDbType("postgres"), false);
assert.equal(isVectorDbType("sqlserver"), false);
});
test("vector system prompt does not contain SQL references", () => {
const prompt = buildSystemPrompt("generate", vectorContext(), "ask");
assert.doesNotMatch(prompt, /```sql/);
assert.doesNotMatch(prompt, /execute_query/);
assert.match(prompt, /collection/);
assert.match(prompt, /REST API/);
});
test("vector agent mode lists vector tools", () => {
const prompt = buildSystemPrompt("generate", vectorContext(), "agent");
assert.match(prompt, /list_collections/);
assert.match(prompt, /browse_collection/);
assert.doesNotMatch(prompt, /execute_query/);
assert.doesNotMatch(prompt, /list_tables/);
});
test("vector focused table prompt warns about unknown collections", () => {
const prompt = buildSystemPrompt("generate", vectorContext({ schemaScope: "focused_table" }), "agent");
assert.match(prompt, /不是完整的集合列表/);
assert.match(prompt, /当前打开的集合/);
assert.match(prompt, /list_collections/);
});
test("vector ask mode mentions REST API format", () => {
const prompt = buildSystemPrompt("generate", vectorContext(), "ask");
assert.match(prompt, /REST API/);
assert.match(prompt, /Qdrant/);
assert.match(prompt, /list_collections/);
assert.match(prompt, /do not browse collection data|不要浏览集合数据/);
assert.doesNotMatch(prompt, /```sql/);
assert.doesNotMatch(prompt, /execute_query/);
});
test("vector system prompt preserves last error and result preview", () => {
const prompt = buildSystemPrompt(
"generate",
vectorContext({
lastError: "Qdrant error",
lastResultPreview: "id | payload\n1 | {}",
}),
"ask",
);
assert.match(prompt, /Last error:\nQdrant error/);
assert.match(prompt, /Last result preview:\nid \| payload/);
});
test("buildUserPrompt skips action instruction for vector databases", () => {
const vectorCtx = vectorContext();
const sqlCtx = context();
const vectorPrompt = buildUserPrompt("generate", vectorCtx, "show me articles", true);
assert.equal(vectorPrompt, "show me articles");
const sqlPrompt = buildUserPrompt("generate", sqlCtx, "show me users", true);
assert.match(sqlPrompt, /Action: generate/);
assert.match(sqlPrompt, /生成 SQL/);
});