mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-29 00:09:52 +00:00
@
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:
@@ -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();
|
||||
|
||||
@@ -956,6 +956,9 @@
|
||||
autoExecute: "Ready to run",
|
||||
notRequested: "Run not requested",
|
||||
skipped: "Not run",
|
||||
callingTool: "Calling tool...",
|
||||
toolDone: "Tool completed",
|
||||
toolError: "Tool error",
|
||||
},
|
||||
agentStepTitles: {
|
||||
riskCheck: "Risk check: {action} · {category} · {environment} · {reasons}",
|
||||
|
||||
@@ -955,6 +955,9 @@
|
||||
autoExecute: "准备执行",
|
||||
notRequested: "未请求执行",
|
||||
skipped: "未执行",
|
||||
callingTool: "正在调用工具...",
|
||||
toolDone: "工具调用完成",
|
||||
toolError: "工具调用出错",
|
||||
},
|
||||
agentStepTitles: {
|
||||
riskCheck: "风险检查:{action} · {category} · {environment} · {reasons}",
|
||||
|
||||
+35
-14
@@ -7,6 +7,8 @@ import { aiTableMentionKey, type AiTableMention } from "@/lib/aiTableMentions";
|
||||
import { aiSkillForAction } from "@/lib/aiSkills";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
|
||||
import type { AgentEvent } from "@/lib/tauri";
|
||||
|
||||
export type AiAction = "generate" | "explain" | "optimize" | "fix" | "convert" | "sampleData";
|
||||
export type AiAssistantMode = "ask" | "agent";
|
||||
|
||||
@@ -25,6 +27,7 @@ export interface AiSchemaTable {
|
||||
}
|
||||
|
||||
export interface AiContext {
|
||||
connectionId: string;
|
||||
connectionName: string;
|
||||
databaseType: DatabaseType;
|
||||
database: string;
|
||||
@@ -44,7 +47,7 @@ export interface AiRequestInput {
|
||||
context: AiContext;
|
||||
}
|
||||
|
||||
export async function runAiAction(input: AiRequestInput, history?: api.AiMessage[]): Promise<string> {
|
||||
function buildAgentRequest(input: AiRequestInput, history?: api.AiMessage[]): { messages: api.AiMessage[]; systemPrompt: string; maxTokens: number; temperature: number } {
|
||||
const isZh = isChineseLocale(currentLocale());
|
||||
const skill = aiSkillForAction(input.action);
|
||||
const systemPrompt = buildSystemPrompt(input.action, input.context, input.mode);
|
||||
@@ -54,27 +57,24 @@ export async function runAiAction(input: AiRequestInput, history?: api.AiMessage
|
||||
const messages: api.AiMessage[] = [...(history || []), { role: "user", content: userPrompt }];
|
||||
|
||||
const params = actionParams(input.action);
|
||||
const maxTokens = input.config.enableThinking ? Math.max(params.maxTokens, 8192) : params.maxTokens;
|
||||
return { messages, systemPrompt, maxTokens, temperature: params.temperature };
|
||||
}
|
||||
|
||||
export async function runAiAction(input: AiRequestInput, history?: api.AiMessage[]): Promise<string> {
|
||||
const { messages, systemPrompt, maxTokens, temperature } = buildAgentRequest(input, history);
|
||||
return api.aiComplete({
|
||||
config: input.config,
|
||||
systemPrompt,
|
||||
messages,
|
||||
maxTokens: params.maxTokens,
|
||||
temperature: params.temperature,
|
||||
maxTokens,
|
||||
temperature,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runAiStream(input: AiRequestInput, history: api.AiMessage[] | undefined, onDelta: (delta: string) => void, sessionId?: string, onReasoningDelta?: (delta: string) => void): Promise<void> {
|
||||
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 messages: api.AiMessage[] = [...(history || []), { role: "user", content: userPrompt }];
|
||||
|
||||
const { messages, systemPrompt, maxTokens, temperature } = buildAgentRequest(input, history);
|
||||
const sid = sessionId || uuid();
|
||||
const params = actionParams(input.action);
|
||||
const maxTokens = input.config.enableThinking ? Math.max(params.maxTokens, 8192) : params.maxTokens;
|
||||
|
||||
await api.aiStream(
|
||||
sid,
|
||||
@@ -83,7 +83,7 @@ export async function runAiStream(input: AiRequestInput, history: api.AiMessage[
|
||||
systemPrompt,
|
||||
messages,
|
||||
maxTokens,
|
||||
temperature: params.temperature,
|
||||
temperature,
|
||||
},
|
||||
(chunk) => {
|
||||
if (!chunk.done) {
|
||||
@@ -94,6 +94,26 @@ export async function runAiStream(input: AiRequestInput, history: api.AiMessage[
|
||||
);
|
||||
}
|
||||
|
||||
export async function runAgentStream(input: AiRequestInput, history: api.AiMessage[] | undefined, onEvent: (event: AgentEvent) => void, sessionId?: string): Promise<string> {
|
||||
const { messages, systemPrompt, maxTokens, temperature } = buildAgentRequest(input, history);
|
||||
const sid = sessionId || uuid();
|
||||
|
||||
return api.aiAgentStream(
|
||||
sid,
|
||||
{
|
||||
config: input.config,
|
||||
systemPrompt,
|
||||
messages,
|
||||
maxTokens,
|
||||
temperature,
|
||||
},
|
||||
input.context.connectionId,
|
||||
input.context.database,
|
||||
input.context.databaseType,
|
||||
onEvent,
|
||||
);
|
||||
}
|
||||
|
||||
function actionParams(action: AiAction): { maxTokens: number; temperature: number } {
|
||||
switch (action) {
|
||||
case "explain":
|
||||
@@ -314,6 +334,7 @@ export async function buildAiContext(tab: QueryTab, connection: ConnectionConfig
|
||||
}
|
||||
|
||||
return {
|
||||
connectionId: tab.connectionId,
|
||||
connectionName: connection.name,
|
||||
databaseType: connection.db_type,
|
||||
database: tab.database,
|
||||
|
||||
@@ -171,6 +171,7 @@ export const buildDataCompareSyncPlan = forward("buildDataCompareSyncPlan");
|
||||
// AI
|
||||
export const aiComplete = forward("aiComplete");
|
||||
export const aiStream = forward("aiStream");
|
||||
export const aiAgentStream = forward("aiAgentStream");
|
||||
export const aiCancelStream = forward("aiCancelStream");
|
||||
export const aiTestConnection = forward("aiTestConnection");
|
||||
export const aiListModels = forward("aiListModels");
|
||||
@@ -349,4 +350,5 @@ export type {
|
||||
TableExportProgress,
|
||||
TableExportStatus,
|
||||
TableExportRequest,
|
||||
AgentEvent,
|
||||
} from "./tauri";
|
||||
|
||||
@@ -744,6 +744,58 @@ export async function aiListModels(config: AiConfig): Promise<AiModelInfo[]> {
|
||||
return post("/api/ai/models", { config });
|
||||
}
|
||||
|
||||
export type { AgentEvent } from "./tauri";
|
||||
|
||||
function isAgentEvent(v: unknown): v is import("./tauri").AgentEvent {
|
||||
return typeof v === "object" && v !== null && "type" in v && typeof (v as Record<string, unknown>).type === "string";
|
||||
}
|
||||
|
||||
export async function aiAgentStream(sessionId: string, request: AiCompletionRequest, connectionId: string, database: string, dbType: string, onEvent: (event: import("./tauri").AgentEvent) => void, signal?: AbortSignal): Promise<string> {
|
||||
const res = await fetch("/api/ai/agent-stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId, request, connection_id: connectionId, database, db_type: dbType }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let result = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data:")) {
|
||||
const data = line.slice(5).trim();
|
||||
if (data && data !== "[DONE]") {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (!isAgentEvent(parsed)) {
|
||||
console.warn("[aiAgentStream] Skipping invalid agent event:", data);
|
||||
continue;
|
||||
}
|
||||
onEvent(parsed);
|
||||
if (parsed.type === "agent_end" || parsed.type === "error") {
|
||||
result = data;
|
||||
}
|
||||
} catch {
|
||||
// skip malformed JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function saveAiConfig(config: AiConfig): Promise<void> {
|
||||
return post("/api/ai/config", { config });
|
||||
}
|
||||
|
||||
@@ -273,6 +273,31 @@ export async function aiStream(sessionId: string, request: AiCompletionRequest,
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentEvent =
|
||||
| { type: "turn_start"; turn: number }
|
||||
| { type: "text_delta"; delta: string }
|
||||
| { type: "reasoning_delta"; delta: string }
|
||||
| { type: "tool_call_start"; tool_call_id: string; tool_name: string; args: Record<string, unknown> }
|
||||
| { type: "tool_call_end"; tool_call_id: string; tool_name: string; result: unknown; is_error: boolean }
|
||||
| { type: "turn_end"; turn: number }
|
||||
| { type: "agent_end"; total_tokens?: number }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
export async function aiAgentStream(sessionId: string, request: AiCompletionRequest, connectionId: string, database: string, dbType: string, onEvent: (event: AgentEvent) => void): Promise<string> {
|
||||
const unlisten: UnlistenFn = await listen<AgentEvent>("ai-agent-event", (event) => {
|
||||
onEvent(event.payload);
|
||||
if (event.payload.type === "agent_end" || event.payload.type === "error") {
|
||||
unlisten();
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await invoke("ai_agent_stream", { sessionId, request, connectionId, database, dbType });
|
||||
} catch (e) {
|
||||
unlisten();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAiConfig(config: AiConfig): Promise<void> {
|
||||
return invoke("save_ai_config", { config });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user