feat(ai): add agent planning and skill prompts
This commit is contained in:
parent
5cbb659d54
commit
82e18ef4f5
|
|
@ -6,9 +6,11 @@ import { translateBackendError } from "@/i18n/backend-errors";
|
|||
import {
|
||||
ArrowUp,
|
||||
ArrowRightLeft,
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CircleSlash,
|
||||
Copy,
|
||||
Database,
|
||||
HelpCircle,
|
||||
|
|
@ -17,6 +19,7 @@ import {
|
|||
MessageSquarePlus,
|
||||
Replace,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Table2,
|
||||
Play,
|
||||
Square,
|
||||
|
|
@ -44,7 +47,8 @@ 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 { extractFirstSqlCodeBlock, shouldAttemptAiAutoExecute } from "@/lib/aiSqlExecutionPolicy";
|
||||
import { buildAiAgentPlan } from "@/lib/aiAgentPlan";
|
||||
import { buildAiAgentStepItems, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/aiAgentStepPresentation";
|
||||
import { Marked } from "marked";
|
||||
import {
|
||||
aiCancelStream,
|
||||
|
|
@ -73,6 +77,7 @@ interface ChatMessage {
|
|||
content: string;
|
||||
reasoning?: string;
|
||||
isThinking?: boolean;
|
||||
agentSteps?: AiAgentStepItem[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -209,6 +214,33 @@ function appendAssistantReasoning(assistantIdx: number, delta: string) {
|
|||
|
||||
const expandedReasoning = ref<Set<number>>(new Set());
|
||||
|
||||
function agentStepIcon(tone: AiAgentStepTone) {
|
||||
if (tone === "danger") return CircleSlash;
|
||||
if (tone === "warning") return AlertTriangle;
|
||||
if (tone === "active") return Play;
|
||||
return ShieldCheck;
|
||||
}
|
||||
|
||||
function agentStepClass(tone: AiAgentStepTone): string {
|
||||
switch (tone) {
|
||||
case "success":
|
||||
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
case "active":
|
||||
return "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300";
|
||||
case "warning":
|
||||
return "border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||
case "danger":
|
||||
return "border-red-500/35 bg-red-500/10 text-red-700 dark:text-red-300";
|
||||
default:
|
||||
return "border-border bg-background/60 text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
function agentStepTitle(step: AiAgentStepItem): string {
|
||||
if (!step.titleKey) return t(step.labelKey);
|
||||
return t(step.titleKey, step.titleParams || {});
|
||||
}
|
||||
|
||||
function toggleReasoning(index: number) {
|
||||
const next = new Set(expandedReasoning.value);
|
||||
if (next.has(index)) {
|
||||
|
|
@ -448,7 +480,7 @@ async function send() {
|
|||
scrollToBottom();
|
||||
|
||||
const requestedAction = activeAction.value;
|
||||
const shouldAutoExecute = assistantMode.value === "agent" && shouldAttemptAiAutoExecute(displayText, requestedAction);
|
||||
const requestedMode = assistantMode.value;
|
||||
isGenerating.value = true;
|
||||
messages.value.push({ role: "assistant", content: "" });
|
||||
const assistantIdx = messages.value.length - 1;
|
||||
|
|
@ -466,6 +498,7 @@ async function send() {
|
|||
{
|
||||
config: settings.aiConfig,
|
||||
action: activeAction.value,
|
||||
mode: requestedMode,
|
||||
instruction: displayText,
|
||||
context,
|
||||
},
|
||||
|
|
@ -484,10 +517,15 @@ async function send() {
|
|||
const msg = messages.value[assistantIdx];
|
||||
if (msg) msg.isThinking = false;
|
||||
isGenerating.value = false;
|
||||
if (shouldAutoExecute) {
|
||||
const sql = extractFirstSqlCodeBlock(msg?.content || "");
|
||||
if (sql) emit("requestAutoExecuteSql", sql);
|
||||
}
|
||||
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();
|
||||
|
|
@ -742,6 +780,18 @@ function formatInlineText(text: string): string {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="msg.agentSteps?.length" class="mb-2 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="step in msg.agentSteps"
|
||||
:key="step.key"
|
||||
class="inline-flex h-5 max-w-full items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium"
|
||||
:class="agentStepClass(step.tone)"
|
||||
:title="agentStepTitle(step)"
|
||||
>
|
||||
<component :is="agentStepIcon(step.tone)" class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate">{{ t(step.labelKey) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<template v-for="(seg, j) in parseMessage(msg.content)" :key="j">
|
||||
<div v-if="seg.type === 'text'" class="ai-markdown whitespace-normal">
|
||||
<div v-html="formatInlineText(seg.content)" />
|
||||
|
|
|
|||
|
|
@ -564,6 +564,26 @@ export default {
|
|||
truncated: "Context truncated",
|
||||
contextSummary: "{database} · {tables} tables",
|
||||
autoSqlBlocked: "The AI-generated SQL looked too risky to auto-execute. Review it manually before running.",
|
||||
agentSteps: {
|
||||
generated: "SQL generated",
|
||||
noSql: "No SQL found",
|
||||
safe: "Safety passed",
|
||||
needsConfirm: "Needs confirmation",
|
||||
blocked: "Blocked",
|
||||
autoExecute: "Ready to run",
|
||||
notRequested: "Run not requested",
|
||||
skipped: "Not run",
|
||||
},
|
||||
agentStepTitles: {
|
||||
riskCheck: "Risk check: {action} · {category} · {environment} · {reasons}",
|
||||
blocked: "Execution was blocked by the risk policy. Review the SQL manually.",
|
||||
requiresConfirmation: "This SQL requires confirmation before execution.",
|
||||
askMode: "Ask mode does not auto-run SQL.",
|
||||
unsupportedAction: "The current AI action does not auto-run SQL.",
|
||||
noSql: "The response did not include an executable SQL code block.",
|
||||
notRequested: "The user did not ask to run the SQL.",
|
||||
skipped: "This step was not run.",
|
||||
},
|
||||
proxy: "Proxy",
|
||||
proxyEnable: "Send AI requests through proxy",
|
||||
proxyUrl: "Proxy URL",
|
||||
|
|
|
|||
|
|
@ -550,6 +550,26 @@ export default {
|
|||
truncated: "上下文已截断",
|
||||
contextSummary: "{database} · {tables} 张表",
|
||||
autoSqlBlocked: "AI 生成的 SQL 风险较高,已阻止自动执行,请手动检查后再运行。",
|
||||
agentSteps: {
|
||||
generated: "已生成 SQL",
|
||||
noSql: "未找到 SQL",
|
||||
safe: "安全检查通过",
|
||||
needsConfirm: "需要确认",
|
||||
blocked: "已阻止",
|
||||
autoExecute: "准备执行",
|
||||
notRequested: "未请求执行",
|
||||
skipped: "未执行",
|
||||
},
|
||||
agentStepTitles: {
|
||||
riskCheck: "风险检查:{action} · {category} · {environment} · {reasons}",
|
||||
blocked: "风险策略阻止执行,请手动检查 SQL。",
|
||||
requiresConfirmation: "此 SQL 需要确认后执行。",
|
||||
askMode: "Ask 模式不会自动执行 SQL。",
|
||||
unsupportedAction: "当前 AI 动作不会自动执行 SQL。",
|
||||
noSql: "回复中没有可执行的 SQL 代码块。",
|
||||
notRequested: "用户没有表达执行意图。",
|
||||
skipped: "此步骤未执行。",
|
||||
},
|
||||
proxy: "代理",
|
||||
proxyEnable: "AI 请求通过代理发送",
|
||||
proxyUrl: "代理地址",
|
||||
|
|
|
|||
140
src/lib/ai.ts
140
src/lib/ai.ts
|
|
@ -12,9 +12,11 @@ import type {
|
|||
import * as api from "@/lib/api";
|
||||
import { currentLocale } from "@/i18n";
|
||||
import { aiTableMentionKey, type AiTableMention } from "@/lib/aiTableMentions";
|
||||
import { aiSkillForAction } from "@/lib/aiSkills";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
|
||||
export type AiAction = "generate" | "explain" | "optimize" | "fix" | "convert" | "sampleData";
|
||||
export type AiAssistantMode = "ask" | "agent";
|
||||
|
||||
export interface AiSchemaTable {
|
||||
schema?: string;
|
||||
|
|
@ -39,41 +41,16 @@ export interface AiContext {
|
|||
export interface AiRequestInput {
|
||||
config: AiConfig;
|
||||
action: AiAction;
|
||||
mode?: AiAssistantMode;
|
||||
instruction: string;
|
||||
context: AiContext;
|
||||
}
|
||||
|
||||
const ACTION_INSTRUCTIONS: Record<AiAction, { en: string; zh: string }> = {
|
||||
generate: {
|
||||
en: "Generate a SQL query that satisfies the user's request. Return the SQL in a ```sql code block first, followed by a brief note if needed. Use foreign key relationships from the schema to infer correct JOIN conditions.",
|
||||
zh: "根据用户请求生成 SQL。先在 ```sql 代码块中返回 SQL,必要时附简短说明。利用 Schema 中的外键关系推断正确的 JOIN 条件。",
|
||||
},
|
||||
explain: {
|
||||
en: "Explain the current SQL step by step. Point out risky operations, implicit assumptions, and potential performance issues. Reference index and foreign key info from the schema when relevant.",
|
||||
zh: "逐步解释当前 SQL。指出危险操作、隐含假设和潜在性能问题。结合 Schema 中的索引和外键信息分析。",
|
||||
},
|
||||
optimize: {
|
||||
en: "Rewrite or suggest improvements for the current SQL. Return the improved SQL in a ```sql code block first, followed by short notes explaining the changes. Use the index information in the schema to suggest index-aware optimizations (e.g., avoid full table scans, leverage existing indexes).",
|
||||
zh: "重写或优化当前 SQL。先在 ```sql 代码块中返回优化后的 SQL,然后简要说明改动。利用 Schema 中的索引信息建议索引友好的优化(如避免全表扫描、利用现有索引)。",
|
||||
},
|
||||
fix: {
|
||||
en: "Fix the current SQL using the provided error message and result context. Return the corrected SQL in a ```sql code block first, followed by a brief explanation of the root cause.",
|
||||
zh: "根据报错信息和结果上下文修复当前 SQL。先在 ```sql 代码块中返回修正后的 SQL,再简要说明根因。",
|
||||
},
|
||||
convert: {
|
||||
en: "Convert the current SQL to the target dialect requested by the user. Return the converted SQL in a ```sql code block first. Note any syntax differences or incompatibilities.",
|
||||
zh: "将当前 SQL 转换为用户指定的目标方言。先在 ```sql 代码块中返回转换后的 SQL,再说明语法差异。",
|
||||
},
|
||||
sampleData: {
|
||||
en: "Generate safe sample INSERT statements or mock data for the current schema. Do not use real production data. Return SQL in a ```sql code block.",
|
||||
zh: "为当前 Schema 生成安全的示例 INSERT 语句或模拟数据。不使用真实生产数据。在 ```sql 代码块中返回 SQL。",
|
||||
},
|
||||
};
|
||||
|
||||
export async function runAiAction(input: AiRequestInput, history?: api.AiMessage[]): Promise<string> {
|
||||
const isZh = currentLocale() === "zh-CN";
|
||||
const systemPrompt = buildSystemPrompt(input.action, input.context);
|
||||
const instruction = isZh ? ACTION_INSTRUCTIONS[input.action].zh : ACTION_INSTRUCTIONS[input.action].en;
|
||||
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,
|
||||
|
|
@ -102,8 +79,9 @@ export async function runAiStream(
|
|||
onReasoningDelta?: (delta: string) => void,
|
||||
): Promise<void> {
|
||||
const isZh = currentLocale() === "zh-CN";
|
||||
const systemPrompt = buildSystemPrompt(input.action, input.context);
|
||||
const instruction = isZh ? ACTION_INSTRUCTIONS[input.action].zh : ACTION_INSTRUCTIONS[input.action].en;
|
||||
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,
|
||||
|
|
@ -152,7 +130,7 @@ export function extractSql(text: string): string {
|
|||
return text.trim();
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(action: AiAction, context: AiContext): string {
|
||||
export function buildSystemPrompt(action: AiAction, context: AiContext, mode: AiAssistantMode = "ask"): string {
|
||||
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` : "";
|
||||
|
|
@ -160,42 +138,16 @@ export function buildSystemPrompt(action: AiAction, context: AiContext): string
|
|||
const isZh = currentLocale() === "zh-CN";
|
||||
|
||||
const lines: string[] = [
|
||||
isZh ? "你是 DBX 内置的数据库助手。用中文回复。" : "You are DBX's built-in database assistant. Reply in English.",
|
||||
isZh
|
||||
? "精确、保守,根据当前数据库方言生成 SQL。"
|
||||
: "Be precise, conservative, and adapt SQL to the active database dialect.",
|
||||
isZh
|
||||
? "下面的 Schema 上下文已包含表、列、索引和外键信息,直接使用即可。不要查询 information_schema 或系统表来获取结构信息。"
|
||||
: "The schema context below already contains tables, columns, indexes, and foreign keys — use it directly. Do NOT query information_schema or system tables.",
|
||||
isZh
|
||||
? "当用户要求分析或查看某个表时,生成 SELECT 查询获取数据,而不是查询元数据。"
|
||||
: "When the user asks to 'analyze' or 'look at' a table, generate a SELECT query to retrieve data, not a metadata query.",
|
||||
isZh ? "不要编造 Schema 中不存在的表或列。" : "Never invent tables or columns that are not in the schema context.",
|
||||
isZh
|
||||
? "用户输入中的 @schema.table 或 @table 表示用户明确提到的表;这些表已优先放入 Schema 上下文。"
|
||||
: "User input may contain @schema.table or @table mentions. Treat them as explicit table references; mentioned tables are prioritized in the schema context.",
|
||||
isZh
|
||||
? "对于 DROP、DELETE、TRUNCATE、ALTER 或没有 WHERE 的 UPDATE,简要警告并优先提供安全的 SELECT 预览。"
|
||||
: "For destructive statements (DROP, DELETE, TRUNCATE, ALTER, UPDATE without WHERE), warn briefly and prefer a safer SELECT preview.",
|
||||
...buildBasePromptLines(isZh),
|
||||
...buildModePromptLines(mode, isZh),
|
||||
...buildActionPromptLines(action, isZh),
|
||||
];
|
||||
|
||||
if (action === "optimize") {
|
||||
if (context.truncated) {
|
||||
lines.push(
|
||||
isZh
|
||||
? "利用 Schema 中的索引信息建议优化。指出哪些查询条件可以命中索引、哪些会导致全表扫描。"
|
||||
: "Use the index information in the schema to suggest optimizations. Point out which conditions hit indexes and which cause full table scans.",
|
||||
);
|
||||
} else if (action === "generate") {
|
||||
lines.push(
|
||||
isZh
|
||||
? "利用外键关系推断 JOIN 条件。生成操作优先返回 SQL,避免长篇解释。"
|
||||
: "Use foreign key relationships to infer JOIN conditions. Return the SQL first and avoid long explanations.",
|
||||
);
|
||||
} else if (action === "fix") {
|
||||
lines.push(
|
||||
isZh
|
||||
? "仔细分析错误信息,定位根因。先返回修正后的 SQL,再简要解释。"
|
||||
: "Carefully analyze the error message to identify the root cause. Return the corrected SQL first, then briefly explain.",
|
||||
? "Schema 已截断:如果请求可能涉及未出现的表或字段,不要猜测。请让用户用 @table 指定相关表,或先生成只读探索查询。"
|
||||
: "Schema is truncated: if the request may involve tables or columns not shown, do not guess. Ask the user to mention the relevant @table, or generate a read-only exploration query first.",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -218,6 +170,66 @@ export function buildSystemPrompt(action: AiAction, context: AiContext): string
|
|||
return lines.filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function buildBasePromptLines(isZh: boolean): string[] {
|
||||
return [
|
||||
isZh ? "你是 DBX 内置的数据库助手。用中文回复。" : "You are DBX's built-in database assistant. Reply in English.",
|
||||
isZh
|
||||
? "精确、保守,根据当前数据库方言生成 SQL。"
|
||||
: "Be precise, conservative, and adapt SQL to the active database dialect.",
|
||||
isZh
|
||||
? "严格使用当前数据库方言;标识符引用、分页、日期函数、字符串拼接、LIMIT/TOP/OFFSET 语法必须匹配数据库类型。"
|
||||
: "Strictly use the active database dialect; identifier quoting, pagination, date functions, string concatenation, and LIMIT/TOP/OFFSET syntax must match the database type.",
|
||||
isZh
|
||||
? "下面的 Schema 上下文已包含表、列、索引和外键信息,直接使用即可。不要查询 information_schema 或系统表来获取结构信息。"
|
||||
: "The schema context below already contains tables, columns, indexes, and foreign keys — use it directly. Do NOT query information_schema or system tables.",
|
||||
isZh
|
||||
? "当用户要求分析或查看某个表时,生成 SELECT 查询获取数据,而不是查询元数据。"
|
||||
: "When the user asks to 'analyze' or 'look at' a table, generate a SELECT query to retrieve data, not a metadata query.",
|
||||
isZh ? "不要编造 Schema 中不存在的表或列。" : "Never invent tables or columns that are not in the schema context.",
|
||||
isZh
|
||||
? "用户输入中的 @schema.table 或 @table 表示用户明确提到的表;这些表已优先放入 Schema 上下文。"
|
||||
: "User input may contain @schema.table or @table mentions. Treat them as explicit table references; mentioned tables are prioritized in the schema context.",
|
||||
isZh
|
||||
? "不要生成多语句 SQL,除非用户明确要求。不要在同一个回答里混合 SELECT 和写操作。"
|
||||
: "Do not generate multi-statement SQL unless the user explicitly asks for it. Do not mix SELECT statements and write operations in the same answer.",
|
||||
isZh
|
||||
? "对于 DROP、DELETE、TRUNCATE、ALTER 或没有 WHERE 的 UPDATE,简要警告并优先提供安全的 SELECT 预览。"
|
||||
: "For destructive statements (DROP, DELETE, TRUNCATE, ALTER, UPDATE without WHERE), warn briefly and prefer a safer SELECT preview.",
|
||||
isZh
|
||||
? "对于 UPDATE 或 DELETE,必须带 WHERE 并说明影响范围;生产库写操作只给建议,不主动建议执行。"
|
||||
: "For UPDATE or DELETE, require a WHERE clause and explain the affected scope; for production writes, provide guidance but do not proactively suggest execution.",
|
||||
];
|
||||
}
|
||||
|
||||
function buildModePromptLines(mode: AiAssistantMode, isZh: boolean): string[] {
|
||||
if (mode === "agent") {
|
||||
return [
|
||||
isZh
|
||||
? "你处于 Agent 模式。用户表达查询意图时,优先生成一个可直接执行的只读 SQL。"
|
||||
: "You are in Agent mode. When the user expresses query intent, prioritize one directly executable read-only SQL statement.",
|
||||
isZh
|
||||
? "第一个 ```sql 代码块只能包含最终推荐执行的 SQL;不要把解释性 SQL、备选 SQL、危险 SQL 放在第一个代码块。"
|
||||
: "The first ```sql code block must contain only the final SQL recommended for execution; do not put explanatory SQL, alternatives, or risky SQL in the first code block.",
|
||||
isZh
|
||||
? "如果安全执行条件不满足,先说明原因,再给只读预览或澄清问题。"
|
||||
: "If safe execution requirements are not met, explain why first, then provide a read-only preview or a clarifying question.",
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
isZh
|
||||
? "你处于 Ask 模式。只生成 SQL 和说明,不要暗示已经执行或即将自动执行。"
|
||||
: "You are in Ask mode. Generate SQL and explanations only; do not imply that anything has run or will auto-run.",
|
||||
];
|
||||
}
|
||||
|
||||
function buildActionPromptLines(action: AiAction, isZh: boolean): string[] {
|
||||
const skill = aiSkillForAction(action);
|
||||
return isZh
|
||||
? [...skill.systemRules.zh, ...skill.outputContract.zh]
|
||||
: [...skill.systemRules.en, ...skill.outputContract.en];
|
||||
}
|
||||
|
||||
function formatSchema(context: AiContext): string {
|
||||
if (!context.tables.length) return "(No table schema loaded.)";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import type { AiAction, AiAssistantMode } from "@/lib/ai";
|
||||
import {
|
||||
classifyAiSqlExecution,
|
||||
shouldAttemptAiAutoExecute,
|
||||
stripAiSqlComments,
|
||||
type AiSqlExecutionDecision,
|
||||
} from "@/lib/aiSqlExecutionPolicy";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
export type AiAgentStep =
|
||||
| { kind: "generate_sql"; status: "done"; sql: string }
|
||||
| { kind: "generate_sql"; status: "skipped"; reason: "no_sql" }
|
||||
| ({
|
||||
kind: "risk_check";
|
||||
status: "done";
|
||||
} & AiSqlExecutionDecision)
|
||||
| { kind: "execute_sql"; status: "pending"; sql: string }
|
||||
| {
|
||||
kind: "execute_sql";
|
||||
status: "skipped";
|
||||
reason:
|
||||
| "ask_mode"
|
||||
| "no_sql"
|
||||
| "no_execution_intent"
|
||||
| "unsupported_action"
|
||||
| "blocked_by_policy"
|
||||
| "requires_confirmation";
|
||||
};
|
||||
|
||||
export interface AiAgentPlanInput {
|
||||
mode: AiAssistantMode;
|
||||
action: AiAction;
|
||||
instruction: string;
|
||||
assistantContent: string;
|
||||
connection?: ConnectionConfig;
|
||||
}
|
||||
|
||||
export interface AiAgentPlan {
|
||||
steps: AiAgentStep[];
|
||||
executableSql?: string;
|
||||
handoffSql?: string;
|
||||
}
|
||||
|
||||
export function buildAiAgentPlan(input: AiAgentPlanInput): AiAgentPlan {
|
||||
const sql = extractFirstExecutableSqlCodeBlock(input.assistantContent);
|
||||
if (!sql) {
|
||||
return {
|
||||
steps: [
|
||||
{ kind: "generate_sql", status: "skipped", reason: "no_sql" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "no_sql" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const steps: AiAgentStep[] = [{ kind: "generate_sql", status: "done", sql }];
|
||||
|
||||
if (input.mode !== "agent") {
|
||||
steps.push({ kind: "execute_sql", status: "skipped", reason: "ask_mode" });
|
||||
return { steps };
|
||||
}
|
||||
|
||||
if (input.action !== "generate") {
|
||||
steps.push({ kind: "execute_sql", status: "skipped", reason: "unsupported_action" });
|
||||
return { steps };
|
||||
}
|
||||
|
||||
if (!shouldAttemptAiAutoExecute(input.instruction, input.action)) {
|
||||
steps.push({ kind: "execute_sql", status: "skipped", reason: "no_execution_intent" });
|
||||
return { steps };
|
||||
}
|
||||
|
||||
const decision = classifyAiSqlExecution(sql, input.connection);
|
||||
steps.push({ kind: "risk_check", status: "done", ...decision });
|
||||
|
||||
if (decision.action === "auto_execute") {
|
||||
steps.push({ kind: "execute_sql", status: "pending", sql });
|
||||
return { steps, executableSql: sql, handoffSql: sql };
|
||||
}
|
||||
|
||||
steps.push({
|
||||
kind: "execute_sql",
|
||||
status: "skipped",
|
||||
reason: decision.action === "block" ? "blocked_by_policy" : "requires_confirmation",
|
||||
});
|
||||
return { steps, handoffSql: sql };
|
||||
}
|
||||
|
||||
function extractFirstExecutableSqlCodeBlock(content: string): string | undefined {
|
||||
const blocks = content.matchAll(/```(?:sql|mysql|postgresql|sqlite|tsql|clickhouse)?\s*\n([\s\S]*?)```/gi);
|
||||
for (const block of blocks) {
|
||||
const sql = block[1]?.trim();
|
||||
if (sql && stripAiSqlComments(sql).trim()) return sql;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import type { AiAgentPlan, AiAgentStep } from "@/lib/aiAgentPlan";
|
||||
|
||||
export type AiAgentStepTone = "success" | "active" | "warning" | "danger" | "muted";
|
||||
|
||||
export interface AiAgentStepItem {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
tone: AiAgentStepTone;
|
||||
titleKey?: string;
|
||||
titleParams?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function buildAiAgentStepItems(plan: AiAgentPlan): AiAgentStepItem[] {
|
||||
return plan.steps.map(presentStep);
|
||||
}
|
||||
|
||||
function presentStep(step: AiAgentStep): AiAgentStepItem {
|
||||
if (step.kind === "generate_sql") {
|
||||
if (step.status === "done") {
|
||||
return { key: "generated", labelKey: "ai.agentSteps.generated", tone: "success" };
|
||||
}
|
||||
return { key: "noSql", labelKey: "ai.agentSteps.noSql", tone: "muted" };
|
||||
}
|
||||
|
||||
if (step.kind === "risk_check") {
|
||||
const title = {
|
||||
titleKey: "ai.agentStepTitles.riskCheck",
|
||||
titleParams: {
|
||||
action: step.action,
|
||||
category: step.category,
|
||||
environment: step.environment,
|
||||
reasons: step.reasons.length ? step.reasons.join(", ") : "-",
|
||||
},
|
||||
};
|
||||
if (step.action === "auto_execute") {
|
||||
return { key: "safe", labelKey: "ai.agentSteps.safe", tone: "success", ...title };
|
||||
}
|
||||
if (step.action === "confirm") {
|
||||
return { key: "needsConfirm", labelKey: "ai.agentSteps.needsConfirm", tone: "warning", ...title };
|
||||
}
|
||||
return { key: "blocked", labelKey: "ai.agentSteps.blocked", tone: "danger", ...title };
|
||||
}
|
||||
|
||||
if (step.status === "pending") {
|
||||
return { key: "autoExecute", labelKey: "ai.agentSteps.autoExecute", tone: "active" };
|
||||
}
|
||||
|
||||
if (step.reason === "no_execution_intent") {
|
||||
return {
|
||||
key: "notRequested",
|
||||
labelKey: "ai.agentSteps.notRequested",
|
||||
titleKey: "ai.agentStepTitles.notRequested",
|
||||
tone: "muted",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: "skipped",
|
||||
labelKey: "ai.agentSteps.skipped",
|
||||
titleKey: skippedTitleKey(step.reason),
|
||||
tone: "muted",
|
||||
};
|
||||
}
|
||||
|
||||
function skippedTitleKey(reason: string): string {
|
||||
switch (reason) {
|
||||
case "blocked_by_policy":
|
||||
return "ai.agentStepTitles.blocked";
|
||||
case "requires_confirmation":
|
||||
return "ai.agentStepTitles.requiresConfirmation";
|
||||
case "ask_mode":
|
||||
return "ai.agentStepTitles.askMode";
|
||||
case "unsupported_action":
|
||||
return "ai.agentStepTitles.unsupportedAction";
|
||||
case "no_sql":
|
||||
return "ai.agentStepTitles.noSql";
|
||||
default:
|
||||
return "ai.agentStepTitles.skipped";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import type { AiAction } from "@/lib/ai";
|
||||
|
||||
export type AiSkillRiskPolicy = "readonly" | "readonly_preferred" | "confirmed_write" | "sample_write";
|
||||
export type AiSkillContextNeed =
|
||||
| "currentSql"
|
||||
| "schema"
|
||||
| "indexes"
|
||||
| "foreignKeys"
|
||||
| "lastError"
|
||||
| "lastResultPreview"
|
||||
| "databaseDialect";
|
||||
|
||||
export interface LocalizedAiSkillText {
|
||||
en: string;
|
||||
zh: string;
|
||||
}
|
||||
|
||||
export interface LocalizedAiSkillLines {
|
||||
en: string[];
|
||||
zh: string[];
|
||||
}
|
||||
|
||||
export interface AiSkillDefinition {
|
||||
id: string;
|
||||
action: AiAction;
|
||||
title: LocalizedAiSkillText;
|
||||
riskPolicy: AiSkillRiskPolicy;
|
||||
contextNeeds: AiSkillContextNeed[];
|
||||
userInstruction: LocalizedAiSkillText;
|
||||
systemRules: LocalizedAiSkillLines;
|
||||
outputContract: LocalizedAiSkillLines;
|
||||
}
|
||||
|
||||
export const AI_SKILL_DEFINITIONS: AiSkillDefinition[] = [
|
||||
{
|
||||
id: "generate_sql",
|
||||
action: "generate",
|
||||
title: {
|
||||
en: "Generate SQL",
|
||||
zh: "生成 SQL",
|
||||
},
|
||||
riskPolicy: "readonly_preferred",
|
||||
contextNeeds: ["schema", "indexes", "foreignKeys", "databaseDialect"],
|
||||
userInstruction: {
|
||||
en: "Generate a SQL query that satisfies the user's request. Return the SQL in a ```sql code block first, followed by a brief note if needed. Use foreign key relationships from the schema to infer correct JOIN conditions.",
|
||||
zh: "根据用户请求生成 SQL。先在 ```sql 代码块中返回 SQL,必要时附简短说明。利用 Schema 中的外键关系推断正确的 JOIN 条件。",
|
||||
},
|
||||
systemRules: {
|
||||
en: ["Use foreign key relationships to infer JOIN conditions. Return the SQL first and avoid long explanations."],
|
||||
zh: ["利用外键关系推断 JOIN 条件。生成操作优先返回 SQL,避免长篇解释。"],
|
||||
},
|
||||
outputContract: {
|
||||
en: [
|
||||
"Output format: put only the final recommended SQL in the first ```sql code block; add at most 3 practical notes after it. If required information is missing, ask one clarifying question first.",
|
||||
],
|
||||
zh: ["输出格式:第一个 ```sql 代码块只放最终推荐 SQL;SQL 后最多 3 条实用说明。信息不足时先提出一个澄清问题。"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "explain_sql",
|
||||
action: "explain",
|
||||
title: {
|
||||
en: "Explain SQL",
|
||||
zh: "解释 SQL",
|
||||
},
|
||||
riskPolicy: "readonly",
|
||||
contextNeeds: ["currentSql", "schema", "indexes", "foreignKeys", "lastResultPreview"],
|
||||
userInstruction: {
|
||||
en: "Explain the current SQL step by step. Point out risky operations, implicit assumptions, and potential performance issues. Reference index and foreign key info from the schema when relevant.",
|
||||
zh: "逐步解释当前 SQL。指出危险操作、隐含假设和潜在性能问题。结合 Schema 中的索引和外键信息分析。",
|
||||
},
|
||||
systemRules: {
|
||||
en: [
|
||||
"Explain what the SQL does without changing it. Reference schema, indexes, foreign keys, result preview, and risky assumptions when relevant.",
|
||||
],
|
||||
zh: ["解释当前 SQL 的作用,不要改写 SQL。必要时结合 Schema、索引、外键、结果预览和风险假设说明。"],
|
||||
},
|
||||
outputContract: {
|
||||
en: [
|
||||
"Output format: summarize the SQL purpose first, then explain execution logic, risks, and performance notes step by step.",
|
||||
],
|
||||
zh: ["输出格式:先概括 SQL 目的,再按步骤解释执行逻辑、风险点和性能注意事项。"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "optimize_sql",
|
||||
action: "optimize",
|
||||
title: {
|
||||
en: "Optimize SQL",
|
||||
zh: "优化 SQL",
|
||||
},
|
||||
riskPolicy: "readonly",
|
||||
contextNeeds: ["currentSql", "schema", "indexes", "foreignKeys"],
|
||||
userInstruction: {
|
||||
en: "Rewrite or suggest improvements for the current SQL. Return the improved SQL in a ```sql code block first, followed by short notes explaining the changes. Use the index information in the schema to suggest index-aware optimizations (e.g., avoid full table scans, leverage existing indexes).",
|
||||
zh: "重写或优化当前 SQL。先在 ```sql 代码块中返回优化后的 SQL,然后简要说明改动。利用 Schema 中的索引信息建议索引友好的优化(如避免全表扫描、利用现有索引)。",
|
||||
},
|
||||
systemRules: {
|
||||
en: [
|
||||
"Use the index information in the schema to suggest optimizations. Point out which conditions hit indexes and which cause full table scans.",
|
||||
],
|
||||
zh: ["利用 Schema 中的索引信息建议优化。指出哪些查询条件可以命中索引、哪些会导致全表扫描。"],
|
||||
},
|
||||
outputContract: {
|
||||
en: ["Output format: provide the optimized SQL first, then explain the key changes in at most 3 notes."],
|
||||
zh: ["输出格式:先给优化后的 SQL,再用最多 3 条说明解释关键改动。"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "fix_sql",
|
||||
action: "fix",
|
||||
title: {
|
||||
en: "Fix SQL",
|
||||
zh: "修复 SQL",
|
||||
},
|
||||
riskPolicy: "readonly_preferred",
|
||||
contextNeeds: ["currentSql", "schema", "lastError", "lastResultPreview", "databaseDialect"],
|
||||
userInstruction: {
|
||||
en: "Fix the current SQL using the provided error message and result context. Return the corrected SQL in a ```sql code block first, followed by a brief explanation of the root cause.",
|
||||
zh: "根据报错信息和结果上下文修复当前 SQL。先在 ```sql 代码块中返回修正后的 SQL,再简要说明根因。",
|
||||
},
|
||||
systemRules: {
|
||||
en: [
|
||||
"Carefully analyze the error message to identify the root cause. Return the corrected SQL first, then briefly explain.",
|
||||
],
|
||||
zh: ["仔细分析错误信息,定位根因。先返回修正后的 SQL,再简要解释。"],
|
||||
},
|
||||
outputContract: {
|
||||
en: ["Output format: corrected SQL, error cause, change notes."],
|
||||
zh: ["输出格式:修复后的 SQL、错误原因、改动说明。"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "convert_sql",
|
||||
action: "convert",
|
||||
title: {
|
||||
en: "Convert SQL Dialect",
|
||||
zh: "转换 SQL 方言",
|
||||
},
|
||||
riskPolicy: "readonly_preferred",
|
||||
contextNeeds: ["currentSql", "schema", "databaseDialect"],
|
||||
userInstruction: {
|
||||
en: "Convert the current SQL to the target dialect requested by the user. Return the converted SQL in a ```sql code block first. Note any syntax differences or incompatibilities.",
|
||||
zh: "将当前 SQL 转换为用户指定的目标方言。先在 ```sql 代码块中返回转换后的 SQL,再说明语法差异。",
|
||||
},
|
||||
systemRules: {
|
||||
en: [
|
||||
"Convert only to the target dialect requested by the user. Preserve the query intent and call out syntax that cannot be converted safely.",
|
||||
],
|
||||
zh: ["只转换到用户指定的目标方言。保持查询意图,并指出无法安全转换的语法。"],
|
||||
},
|
||||
outputContract: {
|
||||
en: [
|
||||
"Output format: provide the converted SQL first, then note important target-dialect syntax differences or incompatibilities.",
|
||||
],
|
||||
zh: ["输出格式:先给转换后的 SQL,再说明目标方言下的重要语法差异或不兼容点。"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sample_data",
|
||||
action: "sampleData",
|
||||
title: {
|
||||
en: "Generate Sample Data",
|
||||
zh: "生成样例数据",
|
||||
},
|
||||
riskPolicy: "sample_write",
|
||||
contextNeeds: ["schema", "databaseDialect"],
|
||||
userInstruction: {
|
||||
en: "Generate safe sample INSERT statements or mock data for the current schema. Do not use real production data. Return SQL in a ```sql code block.",
|
||||
zh: "为当前 Schema 生成安全的示例 INSERT 语句或模拟数据。不使用真实生产数据。在 ```sql 代码块中返回 SQL。",
|
||||
},
|
||||
systemRules: {
|
||||
en: [
|
||||
"Generate mock data only. Do not use or imply real production data, credentials, personal data, or secrets.",
|
||||
],
|
||||
zh: ["只生成模拟数据。不要使用或暗示真实生产数据、凭据、个人数据或密钥。"],
|
||||
},
|
||||
outputContract: {
|
||||
en: ["Output format: provide safe sample SQL first, then explain which values are mock data."],
|
||||
zh: ["输出格式:先给安全的示例 SQL,再说明哪些值是模拟数据。"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function aiSkillForAction(action: AiAction): AiSkillDefinition {
|
||||
const skill = AI_SKILL_DEFINITIONS.find((item) => item.action === action);
|
||||
if (!skill) throw new Error(`Missing AI skill definition for action: ${action}`);
|
||||
return skill;
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildAiAgentPlan } from "../src/lib/aiAgentPlan.ts";
|
||||
import type { AiAction, AiAssistantMode } from "../src/lib/ai.ts";
|
||||
import type { ConnectionConfig } from "../src/types/database.ts";
|
||||
|
||||
function conn(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "c1",
|
||||
name: "local-pg",
|
||||
db_type: "postgres",
|
||||
host: "127.0.0.1",
|
||||
port: 5432,
|
||||
username: "postgres",
|
||||
password: "",
|
||||
database: "app_dev",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function planInput(
|
||||
overrides: {
|
||||
mode?: AiAssistantMode;
|
||||
action?: AiAction;
|
||||
instruction?: string;
|
||||
assistantContent?: string;
|
||||
connection?: ConnectionConfig;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
mode: overrides.mode ?? "agent",
|
||||
action: overrides.action ?? "generate",
|
||||
instruction: overrides.instruction ?? "查一下用户数量",
|
||||
assistantContent: overrides.assistantContent ?? "```sql\nSELECT count(*) FROM users\n```",
|
||||
connection: overrides.connection ?? conn(),
|
||||
};
|
||||
}
|
||||
|
||||
test("ask mode records generated SQL but skips execution", () => {
|
||||
const plan = buildAiAgentPlan(planInput({ mode: "ask" }));
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "ask_mode" },
|
||||
]);
|
||||
assert.equal(plan.executableSql, undefined);
|
||||
});
|
||||
|
||||
test("agent mode auto-executes read SQL when the user asks to query", () => {
|
||||
const plan = buildAiAgentPlan(planInput());
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
|
||||
{
|
||||
kind: "risk_check",
|
||||
status: "done",
|
||||
action: "auto_execute",
|
||||
environment: "non_production",
|
||||
category: "read",
|
||||
reasons: [],
|
||||
},
|
||||
{ kind: "execute_sql", status: "pending", sql: "SELECT count(*) FROM users" },
|
||||
]);
|
||||
assert.equal(plan.executableSql, "SELECT count(*) FROM users");
|
||||
assert.equal(plan.handoffSql, "SELECT count(*) FROM users");
|
||||
});
|
||||
|
||||
test("agent mode skips execution when the user explicitly asks not to run", () => {
|
||||
const plan = buildAiAgentPlan(planInput({ instruction: "只生成 SQL,不要执行" }));
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "no_execution_intent" },
|
||||
]);
|
||||
assert.equal(plan.executableSql, undefined);
|
||||
assert.equal(plan.handoffSql, undefined);
|
||||
});
|
||||
|
||||
test("non-generate actions do not execute even in agent mode", () => {
|
||||
const plan = buildAiAgentPlan(planInput({ action: "optimize", instruction: "优化这条 SQL" }));
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "unsupported_action" },
|
||||
]);
|
||||
assert.equal(plan.executableSql, undefined);
|
||||
assert.equal(plan.handoffSql, undefined);
|
||||
});
|
||||
|
||||
test("agent plan blocks dangerous SQL", () => {
|
||||
const plan = buildAiAgentPlan(
|
||||
planInput({
|
||||
assistantContent: "```sql\nDROP TABLE users\n```",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "done", sql: "DROP TABLE users" },
|
||||
{
|
||||
kind: "risk_check",
|
||||
status: "done",
|
||||
action: "block",
|
||||
environment: "non_production",
|
||||
category: "dangerous",
|
||||
reasons: [],
|
||||
},
|
||||
{ kind: "execute_sql", status: "skipped", reason: "blocked_by_policy" },
|
||||
]);
|
||||
assert.equal(plan.executableSql, undefined);
|
||||
assert.equal(plan.handoffSql, "DROP TABLE users");
|
||||
});
|
||||
|
||||
test("agent plan requires confirmation for production writes", () => {
|
||||
const plan = buildAiAgentPlan(
|
||||
planInput({
|
||||
assistantContent: "```sql\nINSERT INTO users(name) VALUES ('a')\n```",
|
||||
connection: conn({ name: "prod-db", host: "10.0.0.9", database: "app_prod" }),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(plan.steps[1]?.kind, "risk_check");
|
||||
assert.equal(plan.steps[1]?.status, "done");
|
||||
if (plan.steps[1]?.kind === "risk_check") {
|
||||
assert.equal(plan.steps[1].action, "confirm");
|
||||
assert.equal(plan.steps[1].environment, "production");
|
||||
assert.equal(plan.steps[1].category, "low_risk_write");
|
||||
}
|
||||
assert.deepEqual(plan.steps[2], {
|
||||
kind: "execute_sql",
|
||||
status: "skipped",
|
||||
reason: "requires_confirmation",
|
||||
});
|
||||
assert.equal(plan.executableSql, undefined);
|
||||
assert.equal(plan.handoffSql, "INSERT INTO users(name) VALUES ('a')");
|
||||
});
|
||||
|
||||
test("agent plan skips execution when no SQL block is present", () => {
|
||||
const plan = buildAiAgentPlan(planInput({ assistantContent: "我需要更多信息。" }));
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "skipped", reason: "no_sql" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "no_sql" },
|
||||
]);
|
||||
assert.equal(plan.executableSql, undefined);
|
||||
});
|
||||
|
||||
test("agent plan ignores comment-only SQL blocks and executes the first real SQL block", () => {
|
||||
const plan = buildAiAgentPlan(
|
||||
planInput({
|
||||
instruction: "查一下当前数据库里有哪些表",
|
||||
assistantContent: [
|
||||
"当前数据库中的表:",
|
||||
"```sql",
|
||||
"-- 当前数据库中的表(仅从已加载的 Schema 上下文得知):",
|
||||
"-- public.ihli_data",
|
||||
"```",
|
||||
"若需查看该表数据,可执行:",
|
||||
"```sql",
|
||||
"SELECT * FROM ihli_data",
|
||||
"LIMIT 10;",
|
||||
"```",
|
||||
].join("\n"),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "done", sql: "SELECT * FROM ihli_data\nLIMIT 10;" },
|
||||
{
|
||||
kind: "risk_check",
|
||||
status: "done",
|
||||
action: "auto_execute",
|
||||
environment: "non_production",
|
||||
category: "read",
|
||||
reasons: [],
|
||||
},
|
||||
{ kind: "execute_sql", status: "pending", sql: "SELECT * FROM ihli_data\nLIMIT 10;" },
|
||||
]);
|
||||
assert.equal(plan.handoffSql, "SELECT * FROM ihli_data\nLIMIT 10;");
|
||||
});
|
||||
|
||||
test("agent plan treats comment-only SQL responses as no SQL", () => {
|
||||
const plan = buildAiAgentPlan(
|
||||
planInput({
|
||||
assistantContent: "```sql\n-- public.ihli_data\n```",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "skipped", reason: "no_sql" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "no_sql" },
|
||||
]);
|
||||
assert.equal(plan.handoffSql, undefined);
|
||||
});
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildAiAgentStepItems } from "../src/lib/aiAgentStepPresentation.ts";
|
||||
import type { AiAgentPlan } from "../src/lib/aiAgentPlan.ts";
|
||||
|
||||
test("presents auto-execute agent plans as completed generation, safety, and execution steps", () => {
|
||||
const plan: AiAgentPlan = {
|
||||
executableSql: "SELECT count(*) FROM users",
|
||||
handoffSql: "SELECT count(*) FROM users",
|
||||
steps: [
|
||||
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
|
||||
{
|
||||
kind: "risk_check",
|
||||
status: "done",
|
||||
action: "auto_execute",
|
||||
environment: "non_production",
|
||||
category: "read",
|
||||
reasons: [],
|
||||
},
|
||||
{ kind: "execute_sql", status: "pending", sql: "SELECT count(*) FROM users" },
|
||||
],
|
||||
};
|
||||
|
||||
assert.deepEqual(buildAiAgentStepItems(plan), [
|
||||
{ key: "generated", labelKey: "ai.agentSteps.generated", tone: "success" },
|
||||
{
|
||||
key: "safe",
|
||||
labelKey: "ai.agentSteps.safe",
|
||||
titleKey: "ai.agentStepTitles.riskCheck",
|
||||
titleParams: { action: "auto_execute", category: "read", environment: "non_production", reasons: "-" },
|
||||
tone: "success",
|
||||
},
|
||||
{ key: "autoExecute", labelKey: "ai.agentSteps.autoExecute", tone: "active" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("presents blocked and confirmation plans with warning tones", () => {
|
||||
const blocked: AiAgentPlan = {
|
||||
handoffSql: "DROP TABLE users",
|
||||
steps: [
|
||||
{ kind: "generate_sql", status: "done", sql: "DROP TABLE users" },
|
||||
{
|
||||
kind: "risk_check",
|
||||
status: "done",
|
||||
action: "block",
|
||||
environment: "non_production",
|
||||
category: "dangerous",
|
||||
reasons: [],
|
||||
},
|
||||
{ kind: "execute_sql", status: "skipped", reason: "blocked_by_policy" },
|
||||
],
|
||||
};
|
||||
const confirm: AiAgentPlan = {
|
||||
handoffSql: "INSERT INTO users(name) VALUES ('a')",
|
||||
steps: [
|
||||
{ kind: "generate_sql", status: "done", sql: "INSERT INTO users(name) VALUES ('a')" },
|
||||
{
|
||||
kind: "risk_check",
|
||||
status: "done",
|
||||
action: "confirm",
|
||||
environment: "production",
|
||||
category: "low_risk_write",
|
||||
reasons: [],
|
||||
},
|
||||
{ kind: "execute_sql", status: "skipped", reason: "requires_confirmation" },
|
||||
],
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
buildAiAgentStepItems(blocked).map((item) => [item.labelKey, item.tone, item.titleKey, item.titleParams]),
|
||||
[
|
||||
["ai.agentSteps.generated", "success", undefined, undefined],
|
||||
[
|
||||
"ai.agentSteps.blocked",
|
||||
"danger",
|
||||
"ai.agentStepTitles.riskCheck",
|
||||
{ action: "block", category: "dangerous", environment: "non_production", reasons: "-" },
|
||||
],
|
||||
["ai.agentSteps.skipped", "muted", "ai.agentStepTitles.blocked", undefined],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildAiAgentStepItems(confirm).map((item) => [item.labelKey, item.tone, item.titleKey, item.titleParams]),
|
||||
[
|
||||
["ai.agentSteps.generated", "success", undefined, undefined],
|
||||
[
|
||||
"ai.agentSteps.needsConfirm",
|
||||
"warning",
|
||||
"ai.agentStepTitles.riskCheck",
|
||||
{ action: "confirm", category: "low_risk_write", environment: "production", reasons: "-" },
|
||||
],
|
||||
["ai.agentSteps.skipped", "muted", "ai.agentStepTitles.requiresConfirmation", undefined],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("includes risk reasons in risk check titles", () => {
|
||||
const plan: AiAgentPlan = {
|
||||
handoffSql: "INSERT INTO users(name) VALUES ('a'); UPDATE users SET name='b' WHERE id=1",
|
||||
steps: [
|
||||
{
|
||||
kind: "risk_check",
|
||||
status: "done",
|
||||
action: "confirm",
|
||||
environment: "non_production",
|
||||
category: "write",
|
||||
reasons: ["multi_statement"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.deepEqual(buildAiAgentStepItems(plan)[0], {
|
||||
key: "needsConfirm",
|
||||
labelKey: "ai.agentSteps.needsConfirm",
|
||||
titleKey: "ai.agentStepTitles.riskCheck",
|
||||
titleParams: {
|
||||
action: "confirm",
|
||||
category: "write",
|
||||
environment: "non_production",
|
||||
reasons: "multi_statement",
|
||||
},
|
||||
tone: "warning",
|
||||
});
|
||||
});
|
||||
|
||||
test("presents no-sql and no-intent plans as muted skipped states", () => {
|
||||
const noSql: AiAgentPlan = {
|
||||
steps: [
|
||||
{ kind: "generate_sql", status: "skipped", reason: "no_sql" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "no_sql" },
|
||||
],
|
||||
};
|
||||
const noIntent: AiAgentPlan = {
|
||||
steps: [
|
||||
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "no_execution_intent" },
|
||||
],
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
buildAiAgentStepItems(noSql).map((item) => item.labelKey),
|
||||
["ai.agentSteps.noSql", "ai.agentSteps.skipped"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildAiAgentStepItems(noIntent).map((item) => item.labelKey),
|
||||
["ai.agentSteps.generated", "ai.agentSteps.notRequested"],
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import type { AiContext } from "../src/lib/ai.ts";
|
||||
|
||||
class MemoryStorage {
|
||||
private values = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.values.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: new MemoryStorage(),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { buildSystemPrompt } = await import("../src/lib/ai.ts");
|
||||
|
||||
function context(overrides: Partial<AiContext> = {}): AiContext {
|
||||
return {
|
||||
connectionName: "prod-analytics",
|
||||
databaseType: "postgres",
|
||||
database: "app",
|
||||
currentSql: "",
|
||||
tables: [
|
||||
{
|
||||
schema: "public",
|
||||
name: "orders",
|
||||
tableType: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "uuid", is_nullable: false, is_primary_key: true },
|
||||
{ name: "user_id", data_type: "uuid", is_nullable: false },
|
||||
{ name: "total", data_type: "numeric", is_nullable: false },
|
||||
],
|
||||
indexes: [{ name: "idx_orders_user_id", columns: ["user_id"], is_unique: false, is_primary: false }],
|
||||
foreignKeys: [{ column: "user_id", ref_table: "users", ref_column: "id" }],
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("agent mode prompt makes the first SQL block the executable recommendation", () => {
|
||||
const prompt = buildSystemPrompt("generate", context(), "agent");
|
||||
|
||||
assert.match(prompt, /Agent 模式/);
|
||||
assert.match(prompt, /第一个 ```sql 代码块只能包含最终推荐执行的 SQL/);
|
||||
assert.match(prompt, /不要把解释性 SQL、备选 SQL、危险 SQL 放在第一个代码块/);
|
||||
});
|
||||
|
||||
test("ask mode prompt forbids auto-execution assumptions", () => {
|
||||
const prompt = buildSystemPrompt("generate", context(), "ask");
|
||||
|
||||
assert.match(prompt, /Ask 模式/);
|
||||
assert.match(prompt, /只生成 SQL 和说明/);
|
||||
assert.match(prompt, /不要暗示已经执行或即将自动执行/);
|
||||
});
|
||||
|
||||
test("prompt gives explicit guidance for truncated schema context", () => {
|
||||
const prompt = buildSystemPrompt("generate", context({ truncated: true }), "ask");
|
||||
|
||||
assert.match(prompt, /Schema context is truncated/);
|
||||
assert.match(prompt, /如果请求可能涉及未出现的表或字段,不要猜测/);
|
||||
assert.match(prompt, /@table/);
|
||||
});
|
||||
|
||||
test("prompt enforces database dialect and single executable statement safety", () => {
|
||||
const prompt = buildSystemPrompt("generate", context({ databaseType: "sqlserver" }), "agent");
|
||||
|
||||
assert.match(prompt, /严格使用当前数据库方言/);
|
||||
assert.match(prompt, /分页、日期函数、字符串拼接/);
|
||||
assert.match(prompt, /不要生成多语句 SQL/);
|
||||
assert.match(prompt, /不要在同一个回答里混合 SELECT 和写操作/);
|
||||
});
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import type { AiAction, AiAssistantMode, AiContext } from "../src/lib/ai.ts";
|
||||
|
||||
class MemoryStorage {
|
||||
private values = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.values.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: new MemoryStorage(),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { buildSystemPrompt } = await import("../src/lib/ai.ts");
|
||||
|
||||
function baseContext(overrides: Partial<AiContext> = {}): AiContext {
|
||||
return {
|
||||
connectionName: "prod-analytics",
|
||||
databaseType: "postgres",
|
||||
database: "warehouse",
|
||||
currentSql: "select user_id, count(*) from public.orders group by user_id",
|
||||
lastError: undefined,
|
||||
lastResultPreview: 'user_id="u1", count=3\nuser_id="u2", count=7',
|
||||
tables: [
|
||||
{
|
||||
schema: "public",
|
||||
name: "orders",
|
||||
tableType: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "uuid", is_nullable: false, is_primary_key: true },
|
||||
{ name: "user_id", data_type: "uuid", is_nullable: false },
|
||||
{ name: "created_at", data_type: "timestamp", is_nullable: false },
|
||||
{ name: "total", data_type: "numeric", is_nullable: false },
|
||||
],
|
||||
indexes: [
|
||||
{ name: "idx_orders_user_id", columns: ["user_id"], is_unique: false, is_primary: false },
|
||||
{ name: "idx_orders_created_at", columns: ["created_at"], is_unique: false, is_primary: false },
|
||||
],
|
||||
foreignKeys: [{ column: "user_id", ref_table: "users", ref_column: "id" }],
|
||||
},
|
||||
{
|
||||
schema: "public",
|
||||
name: "users",
|
||||
tableType: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "uuid", is_nullable: false, is_primary_key: true },
|
||||
{ name: "email", data_type: "text", is_nullable: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface PromptEvalCase {
|
||||
name: string;
|
||||
action: AiAction;
|
||||
mode: AiAssistantMode;
|
||||
context?: Partial<AiContext>;
|
||||
mustInclude: RegExp[];
|
||||
mustNotInclude?: RegExp[];
|
||||
}
|
||||
|
||||
const cases: PromptEvalCase[] = [
|
||||
{
|
||||
name: "agent generate keeps the first SQL block executable and read-oriented",
|
||||
action: "generate",
|
||||
mode: "agent",
|
||||
mustInclude: [
|
||||
/Agent 模式/,
|
||||
/可直接执行的只读 SQL/,
|
||||
/第一个 ```sql 代码块只能包含最终推荐执行的 SQL/,
|
||||
/不要把解释性 SQL、备选 SQL、危险 SQL 放在第一个代码块/,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "ask generate never implies auto execution",
|
||||
action: "generate",
|
||||
mode: "ask",
|
||||
mustInclude: [/Ask 模式/, /只生成 SQL 和说明/, /不要暗示已经执行或即将自动执行/],
|
||||
},
|
||||
{
|
||||
name: "truncated schema blocks guessing and points users to table mentions",
|
||||
action: "generate",
|
||||
mode: "ask",
|
||||
context: { truncated: true },
|
||||
mustInclude: [/Schema context is truncated/, /不要猜测/, /@table/, /只读探索查询/],
|
||||
},
|
||||
{
|
||||
name: "sqlserver generation requires dialect-specific pagination and quoting",
|
||||
action: "generate",
|
||||
mode: "agent",
|
||||
context: { databaseType: "sqlserver" },
|
||||
mustInclude: [/Database type: sqlserver/, /严格使用当前数据库方言/, /LIMIT\/TOP\/OFFSET/],
|
||||
},
|
||||
{
|
||||
name: "optimization uses index evidence and calls out full scans",
|
||||
action: "optimize",
|
||||
mode: "ask",
|
||||
mustInclude: [/idx_orders_user_id/, /索引信息/, /全表扫描/, /最多 3 条说明/],
|
||||
},
|
||||
{
|
||||
name: "fixing SQL includes the backend error and a corrected SQL contract",
|
||||
action: "fix",
|
||||
mode: "ask",
|
||||
context: { lastError: 'column "userid" does not exist' },
|
||||
mustInclude: [/Last error:\ncolumn "userid" does not exist/, /修正后的 SQL/, /错误原因/, /改动说明/],
|
||||
},
|
||||
{
|
||||
name: "explain action keeps execution logic, risks, and performance in scope",
|
||||
action: "explain",
|
||||
mode: "ask",
|
||||
mustInclude: [/Current SQL:\nselect user_id/, /执行逻辑/, /风险点/, /性能注意事项/],
|
||||
},
|
||||
{
|
||||
name: "convert action requires target dialect caveats",
|
||||
action: "convert",
|
||||
mode: "ask",
|
||||
mustInclude: [/转换后的 SQL/, /目标方言/, /语法差异或不兼容点/],
|
||||
},
|
||||
{
|
||||
name: "sample data action separates mock data from real production data",
|
||||
action: "sampleData",
|
||||
mode: "ask",
|
||||
mustInclude: [/安全的示例 SQL/, /模拟数据/, /生产库写操作只给建议/],
|
||||
},
|
||||
{
|
||||
name: "base prompt keeps destructive SQL and multi-statement safety rails",
|
||||
action: "generate",
|
||||
mode: "agent",
|
||||
mustInclude: [/不要生成多语句 SQL/, /不要在同一个回答里混合 SELECT 和写操作/, /UPDATE 或 DELETE,必须带 WHERE/],
|
||||
},
|
||||
{
|
||||
name: "schema evidence includes foreign keys and indexes for join reasoning",
|
||||
action: "generate",
|
||||
mode: "ask",
|
||||
mustInclude: [/FK: user_id → users\.id/, /Index: idx_orders_user_id\(user_id\)/, /利用外键关系推断 JOIN 条件/],
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
test(`AI prompt eval: ${item.name}`, () => {
|
||||
const prompt = buildSystemPrompt(item.action, baseContext(item.context), item.mode);
|
||||
|
||||
for (const pattern of item.mustInclude) {
|
||||
assert.match(prompt, pattern, `${item.name} should include ${pattern}`);
|
||||
}
|
||||
|
||||
for (const pattern of item.mustNotInclude ?? []) {
|
||||
assert.doesNotMatch(prompt, pattern, `${item.name} should not include ${pattern}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import type { AiAction } from "../src/lib/ai.ts";
|
||||
import { AI_SKILL_DEFINITIONS, aiSkillForAction } from "../src/lib/aiSkills.ts";
|
||||
|
||||
const actions: AiAction[] = ["generate", "explain", "optimize", "fix", "convert", "sampleData"];
|
||||
|
||||
test("defines one internal AI skill per assistant action", () => {
|
||||
assert.deepEqual(AI_SKILL_DEFINITIONS.map((skill) => skill.action).sort(), [...actions].sort());
|
||||
|
||||
for (const action of actions) {
|
||||
const skill = aiSkillForAction(action);
|
||||
|
||||
assert.equal(skill.action, action);
|
||||
assert.ok(skill.id.endsWith("_sql") || skill.id === "sample_data");
|
||||
assert.ok(skill.title.zh);
|
||||
assert.ok(skill.title.en);
|
||||
assert.ok(skill.contextNeeds.length > 0);
|
||||
assert.ok(skill.systemRules.zh.length > 0);
|
||||
assert.ok(skill.systemRules.en.length > 0);
|
||||
assert.ok(skill.userInstruction.zh.includes("SQL"));
|
||||
assert.ok(skill.userInstruction.en.includes("SQL"));
|
||||
}
|
||||
});
|
||||
|
||||
test("captures safety and output contracts for agent-ready skills", () => {
|
||||
const generate = aiSkillForAction("generate");
|
||||
assert.equal(generate.riskPolicy, "readonly_preferred");
|
||||
assert.match(generate.outputContract.zh.join("\n"), /第一个 ```sql 代码块/);
|
||||
assert.match(generate.systemRules.zh.join("\n"), /外键关系/);
|
||||
|
||||
const optimize = aiSkillForAction("optimize");
|
||||
assert.equal(optimize.riskPolicy, "readonly");
|
||||
assert.deepEqual(optimize.contextNeeds, ["currentSql", "schema", "indexes", "foreignKeys"]);
|
||||
assert.match(optimize.systemRules.zh.join("\n"), /索引信息/);
|
||||
assert.match(optimize.outputContract.zh.join("\n"), /最多 3 条说明/);
|
||||
|
||||
const fix = aiSkillForAction("fix");
|
||||
assert.ok(fix.contextNeeds.includes("lastError"));
|
||||
assert.match(fix.outputContract.zh.join("\n"), /错误原因/);
|
||||
});
|
||||
Loading…
Reference in New Issue