feat(ai): add agent loop with tool calling, fix connection routing and Gemini compatibility

## 背景说明
将 dbx AI 助手从单次 SQL 文本生成器升级为具备 tool calling 的数据库 Agent。
Phase 1 实现 Agent Loop 框架 + 只读工具(list_tables、get_columns)。
参考:Pi agent-core,.trellis/tasks/06-11-feat-ai-agent-loop-tool-calling-upgrade

## 修改内容
- 新增 agent_loop.rs:Agent Loop 核心,支持 OpenAI/Claude/Gemini Provider 的 tool calling,最大 10 轮,Ollama 自动降级为 text-only
- 新增 agent_tools.rs:read_only_tools (list_tables, get_columns) 的工具定义与执行器
- 新增 agent_events.rs:AgentEvent/ToolCall/ToolResult/ToolDefinition 类型定义
- 修改 ai.rs:AiMessage 新增 tool_call_id 字段
- 修改 lib.rs:注册 agent_events、agent_loop、agent_tools 模块
- 修改 routes/ai.rs & commands/ai.rs:新增 ai_agent_stream SSE 端点
- 修改 tauri.ts & http.ts:新增 AgentEvent 类型与 aiAgentStream 函数
- 修改 api.ts:注册 aiAgentStream 转发
- 修改 ai.ts:新增 runAgentStream,AiContext 增加 connectionId 字段
- 修改 AiAssistant.vue:接入 runAgentStream,解析 AgentEvent 渲染工具调用步骤
- 修改 en.ts / zh-CN.ts:新增 agentSteps 国际化文案

## 修复的问题
- [#I01] connectionName 误传为 connectionId → add connectionId to AiContext
- [#I02] agent loop 取消操作无效 → add cancel checks before each turn
- [#C01] TODO 缺少 issue 引用 → add PRD reference to TODO
- [#C02] stream_with_tools 命名不准确 → add clarifying doc comment
- [#C03] Gemini functionResponse.name 解析错误 → fix tool_call_id format and extraction

## 测试建议
- execute_get_columns:合法表名/空表名/SQL 注入字符拒绝
- call_openai_with_tools:空 choices/空 tool_calls/非 JSON arguments
- call_gemini_with_tools:空 candidates/parts 无 functionCall
- runAgentStream:有效 connectionId 正常调用
@
This commit is contained in:
runstone committed 2026-06-11 18:27:24 +08:00
1 parent 759cab9088
commit 9ab41795d6
17 files changed
+1253 -122

No files matched your search

+42
View File
@@ -42,11 +42,53 @@ pub async fn ai_stream(app: AppHandle, session_id: String, request: AiCompletion
result
}
use dbx_core::agent_events::AgentEvent;
use dbx_core::agent_loop::{run_agent_loop, AgentLoopContext};
use dbx_core::models::connection::DatabaseType;
#[tauri::command]
pub async fn ai_cancel_stream(session_id: String) -> Result<bool, String> {
Ok(dbx_core::ai::cancel_stream(&session_id).await)
}
#[tauri::command]
pub async fn ai_agent_stream(
app: AppHandle,
state: State<'_, Arc<AppState>>,
session_id: String,
request: AiCompletionRequest,
connection_id: String,
database: String,
db_type: String,
) -> Result<String, String> {
let cancelled = dbx_core::ai::register_stream(&session_id).await;
let parsed_db_type: DatabaseType =
serde_json::from_str(&format!("\"{}\"", db_type)).map_err(|_| format!("Unknown database type: {db_type}"))?;
let agent_ctx = AgentLoopContext { state: state.inner().clone(), connection_id, database, db_type: parsed_db_type };
let result = run_agent_loop(
&request.config,
&request.system_prompt,
&request.messages,
&agent_ctx,
{
let app = app.clone();
move |event: AgentEvent| {
let _ = app.emit("ai-agent-event", &event);
}
},
&cancelled,
request.max_tokens,
request.temperature,
)
.await;
dbx_core::ai::unregister_stream(&session_id).await;
result
}
#[tauri::command]
pub async fn save_ai_conversation(state: State<'_, Arc<AppState>>, conversation: AiConversation) -> Result<(), String> {
state.storage.save_ai_conversation(&conversation).await