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

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
import { computed, nextTick, onMounted, onUnmounted, ref, type Component } from "vue";
import { uuid } from "@/lib/utils";
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
@@ -16,7 +16,8 @@ import { connectionIconType } from "@/lib/connectionPresentation";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useQueryStore } from "@/stores/queryStore";
import { useToast } from "@/composables/useToast";
import { buildAiContext, runAiStream, type AiAction } from "@/lib/ai";
import { buildAiContext, runAgentStream, type AiAction } from "@/lib/ai";
import type { AgentEvent } from "@/lib/tauri";
import { buildAiAgentPlan } from "@/lib/aiAgentPlan";
import { buildAiAgentStepItems, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/aiAgentStepPresentation";
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/aiCodeHighlighter";
@@ -90,7 +91,7 @@ const selectedMentions = ref<AiTableMention[]>([]);
let mentionTimer: ReturnType<typeof setTimeout> | undefined;
let mentionRequestId = 0;
const actionButtons: { action: AiAction; icon: any; key: string }[] = [
const actionButtons: { action: AiAction; icon: Component; key: string }[] = [
{ action: "generate", icon: Wand2, key: "ai.actions.generate" },
{ action: "explain", icon: HelpCircle, key: "ai.actions.explain" },
{ action: "optimize", icon: Zap, key: "ai.actions.optimize" },
@@ -199,8 +200,9 @@ async function changeConnection(connectionId: string) {
if (tab) {
queryStore.updateDatabase(tab.id, database);
}
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
toast(t("connection.connectFailed", { message: translateBackendError(t, message) }), 5000);
}
}
@@ -355,9 +357,10 @@ async function loadMentionCandidates(query: string) {
mentionCache.value[key] = candidates.slice(0, 40);
mentionCandidates.value = mentionCache.value[key];
mentionSelectedIndex.value = 0;
} catch (e: any) {
} catch (e: unknown) {
if (requestId !== mentionRequestId) return;
mentionError.value = translateBackendError(t, e?.message || String(e));
const message = e instanceof Error ? e.message : String(e);
mentionError.value = translateBackendError(t, message);
mentionCandidates.value = [];
} finally {
if (requestId === mentionRequestId) mentionLoading.value = false;
@@ -484,6 +487,7 @@ async function send() {
const assistantIdx = messages.value.length - 1;
const sessionId = uuid();
currentSessionId.value = sessionId;
const agentEvents: AgentEvent[] = [];
try {
const context = await buildAiContext(props.tab, props.connection, {
mentionedTables,
@@ -492,7 +496,7 @@ async function send() {
role: m.role,
content: m.content,
}));
await runAiStream(
await runAgentStream(
{
config: settings.aiConfig,
action: activeAction.value,
@@ -501,29 +505,49 @@ async function send() {
context,
},
history,
(delta) => {
appendAssistantDelta(assistantIdx, delta);
(event: AgentEvent) => {
agentEvents.push(event);
if (event.type === "text_delta" && event.delta) {
appendAssistantDelta(assistantIdx, event.delta);
}
if (event.type === "reasoning_delta" && event.delta) {
appendAssistantReasoning(assistantIdx, event.delta);
}
scrollToBottom();
},
sessionId,
(reasoningDelta) => {
appendAssistantReasoning(assistantIdx, reasoningDelta);
},
);
} catch (e: any) {
messages.value[assistantIdx].content = `Error: ${e.message || e}`;
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
messages.value[assistantIdx].content = `Error: ${message}`;
} finally {
const msg = messages.value[assistantIdx];
if (msg) msg.isThinking = false;
isGenerating.value = false;
const agentPlan = buildAiAgentPlan({
mode: requestedMode,
action: requestedAction,
instruction: displayText,
assistantContent: msg?.content || "",
connection: props.connection,
});
if (msg && requestedMode === "agent") msg.agentSteps = buildAiAgentStepItems(agentPlan);
if (agentPlan.handoffSql) emit("requestAutoExecuteSql", agentPlan.handoffSql);
// Render agent tool call steps from agent events
if (msg && agentEvents.length > 0) {
msg.agentSteps = agentEvents
.filter((e) => e.type === "tool_call_start" || e.type === "tool_call_end")
.map((e) => ({
key: `${e.tool_call_id || ""}-${e.type}`,
labelKey: e.type === "tool_call_start" ? "ai.agentSteps.callingTool" : e.is_error ? "ai.agentSteps.toolError" : "ai.agentSteps.toolDone",
tone: e.type === "tool_call_start" ? "active" : e.is_error ? "danger" : "success",
titleKey: undefined,
titleParams: { tool: e.tool_name || "" },
}));
}
// Fallback: use aiAgentPlan for backward compatibility
if (msg && !msg.agentSteps?.length) {
const agentPlan = buildAiAgentPlan({
mode: requestedMode,
action: requestedAction,
instruction: displayText,
assistantContent: msg?.content || "",
connection: props.connection,
});
if (msg && requestedMode === "agent") msg.agentSteps = buildAiAgentStepItems(agentPlan);
if (agentPlan.handoffSql) emit("requestAutoExecuteSql", agentPlan.handoffSql);
}
activeAction.value = "generate";
currentSessionId.value = "";
persistConversation();
@@ -555,8 +579,9 @@ async function copyCode(code: string, key: string) {
setTimeout(() => {
if (copiedIndex.value === key) copiedIndex.value = "";
}, 2000);
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
toast(t("grid.copyFailed", { message }), 5000);
}
}
@@ -620,6 +645,7 @@ onMounted(async () => {
onUnmounted(() => {
clearTimeout(mentionTimer);
cancelStream();
});
function triggerAction(action: AiAction, instruction?: string) {
@@ -781,7 +807,7 @@ const messageRenderer = computed(() => {
<div v-if="connectionStore.connections.length" class="flex items-center gap-1 mb-1 text-xs text-foreground/80">
<DatabaseIcon v-if="connection" :db-type="connectionIconType(connection)" class="h-3 w-3 shrink-0" />
<Server v-else class="h-3 w-3 shrink-0" />
<Select :model-value="connection?.id || ''" @update:model-value="(v: any) => changeConnection(v)">
<Select :model-value="connection?.id || ''" @update:model-value="(v: string) => changeConnection(v)">
<SelectTrigger class="h-5 w-auto border-0 rounded-md bg-transparent dark:bg-transparent p-0 px-1 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3">
<SelectValue :placeholder="t('editor.selectConnection')">{{ connection?.name || t("editor.selectConnection") }}</SelectValue>
</SelectTrigger>
@@ -798,7 +824,7 @@ const messageRenderer = computed(() => {
<Database class="h-3 w-3 shrink-0 text-foreground/40" />
<Select
:model-value="selectedDatabaseSelectValue"
@update:model-value="(v: any) => changeDatabase(v)"
@update:model-value="(v: string) => changeDatabase(v)"
@update:open="
(open: boolean) => {
if (open) loadDatabases();