feat(ai): add agent planning and skill prompts

This commit is contained in:
t8y2 2026-05-16 18:34:04 +08:00
parent 5cbb659d54
commit 82e18ef4f5
12 changed files with 1176 additions and 70 deletions

View File

@ -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)" />

View File

@ -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",

View File

@ -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: "代理地址",

View File

@ -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.)";

95
src/lib/aiAgentPlan.ts Normal file
View File

@ -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;
}

View File

@ -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";
}
}

189
src/lib/aiSkills.ts Normal file
View File

@ -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 代码块只放最终推荐 SQLSQL 后最多 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;
}

193
tests/aiAgentPlan.test.ts Normal file
View File

@ -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);
});

View File

@ -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"],
);
});

88
tests/aiPrompt.test.ts Normal file
View File

@ -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 和写操作/);
});

170
tests/aiPromptEval.test.ts Normal file
View File

@ -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}`);
}
});
}

41
tests/aiSkills.test.ts Normal file
View File

@ -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"), /错误原因/);
});