fix(ai): harden write SQL confirmation flow

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-07-28 11:56:23 +08:00
1 parent 1e72d28ed3
commit cb9d0f7cbf
20 files changed
+1258 -51

No files matched your search

+26
View File
@@ -31,4 +31,30 @@ describe("AI SQL dialect prompt", () => {
expect(prompt).toContain('double quotes "name"');
expect(prompt).toContain("Do not switch dialects merely because the user mentions another database in prose.");
});
it("agent mode instructs to ask for write confirmation instead of blocking", () => {
const prompt = buildSystemPrompt("general", context(), "agent");
expect(prompt).not.toContain("explain why it is blocked");
expect(prompt).toContain("ask for explicit confirmation");
expect(prompt).toContain("Never execute writes without confirmation");
});
it("agent mode zh instructs to ask for write confirmation instead of blocking", async () => {
await setLocale("zh-CN");
const prompt = buildSystemPrompt("general", context(), "agent");
expect(prompt).not.toContain("不要执行");
expect(prompt).toContain("明确询问用户是否确认执行");
expect(prompt).toContain("禁止不经确认直接执行写入");
await setLocale("en");
});
it("ask mode does not include agent write-confirmation prompt", () => {
const prompt = buildSystemPrompt("general", context(), "ask");
expect(prompt).not.toContain("ask for explicit confirmation");
expect(prompt).not.toContain("Never execute writes without confirmation");
expect(prompt).toContain("Ask mode");
});
});
@@ -0,0 +1,368 @@
import { describe, expect, it } from "vitest";
import { containsWriteSql, looksLikeActionProposal, looksLikeWriteSqlProposal, shouldGrantWriteSqlOnShortAffirmative, type WriteSqlGrantParams } from "@/lib/ai/aiProposalDetect";
import { aiSkillForAction } from "@/lib/ai/aiSkills";
import { extractFirstSqlCodeBlock, extractSingleSqlCodeBlock, countSqlCodeBlocks } from "@/lib/ai/aiSqlExecutionPolicy";
// ── looksLikeWriteSqlProposal ──────────────────────────────────────────────
describe("looksLikeWriteSqlProposal", () => {
it("detects zh write-SQL proposal (CREATE in last line)", () => {
expect(looksLikeWriteSqlProposal("需要我执行 CREATE TABLE users 吗?")).toBe(true);
});
it("detects en write-SQL proposal (INSERT in last line)", () => {
expect(looksLikeWriteSqlProposal("Should I run this INSERT?")).toBe(true);
});
it("detects write-SQL proposal split across lines", () => {
const multi = "已经分析了表结构。\n需要我执行这条 DELETE 语句吗?";
expect(looksLikeWriteSqlProposal(multi)).toBe(true);
});
it("detects generic confirmation phrase when message has SQL code block elsewhere", () => {
// The prompt instructs the model to ask "需要我执行这条 SQL 吗?"
// This has no write keywords in the last line, but the message body
// has CREATE TABLE in a code block — `looksLikeWriteSqlProposal` must
// check the whole message for write keywords, not just the last line.
const withCodeBlock = "以下是建表 SQL:\n```sql\nCREATE TABLE users (id INT);\n```\n需要我执行这条 SQL 吗?";
expect(looksLikeWriteSqlProposal(withCodeBlock)).toBe(true);
});
it("detects en generic confirmation with SQL code block elsewhere", () => {
const en = "Here is the SQL:\n```sql\nINSERT INTO users VALUES (1);\n```\nShould I run this?";
expect(looksLikeWriteSqlProposal(en)).toBe(true);
});
it("rejects proposal where write keyword is in an earlier line but last line asks about reading", () => {
// DELETE appears in first line; the proposal (last line) is about a read query.
// With the safety rule `lastLineMentionsWriteSql`, this is rejected because
// the last line does NOT explicitly reference the write SQL or operation.
const ambiguous = "DELETE 语句会删除所有匹配记录。\n需要我帮你查询一下删除后还剩多少条数据吗?";
expect(looksLikeActionProposal(ambiguous)).toBe(true);
expect(containsWriteSql(ambiguous)).toBe(true);
// REJECTED: last line asks about 查询 (read), not 执行 (write)
expect(looksLikeWriteSqlProposal(ambiguous)).toBe(false);
});
it("rejects read-only proposal even if write keyword appears elsewhere", () => {
const content = "DROP TABLE is dangerous.\nShould I list the tables for you?";
expect(containsWriteSql(content)).toBe(true);
// REJECTED: last line asks about "list" (read), not "execute" (write), and
// there is no SQL code block connection
expect(looksLikeWriteSqlProposal(content)).toBe(false);
});
it("rejects pure explanation without proposal", () => {
expect(looksLikeWriteSqlProposal("DELETE 会删除数据,你手动执行吧。")).toBe(false);
});
it("rejects empty content", () => {
expect(looksLikeWriteSqlProposal("")).toBe(false);
});
// Safety rule: last line must reference write SQL or write operation
it("rejects zh proposal where last line asks about read even though message has DELETE", () => {
// DELETE keyword in first sentence, but last line is about 查询 (read).
// Without lastLineMentionsWriteSql this was a false-positive.
const msg = "DELETE 语句会删除所有匹配记录。\n需要我帮你查询一下删除后还剩多少条数据吗?";
expect(looksLikeWriteSqlProposal(msg)).toBe(false);
});
it("rejects en proposal where last line asks about list even though message has DROP", () => {
const msg = "DROP TABLE is dangerous.\nShould I list the tables for you?";
expect(looksLikeWriteSqlProposal(msg)).toBe(false);
});
it("accepts zh proposal referencing '这条 SQL' when message has write SQL code block", () => {
const msg = "以下是建表 SQL:\n```sql\nCREATE TABLE users (id INT);\n```\n需要我执行这条 SQL 吗?";
expect(looksLikeWriteSqlProposal(msg)).toBe(true);
});
it("accepts en proposal referencing 'this SQL' when message has write SQL code block", () => {
const msg = "Here is the SQL:\n```sql\nINSERT INTO users VALUES (1);\n```\nShould I run this SQL?";
expect(looksLikeWriteSqlProposal(msg)).toBe(true);
});
it("rejects zh proposal: code block present but last line asks about 查询", () => {
// Has a write SQL code block, but the last line explicitly asks about a
// read operation (查询). This should NOT grant write permission.
const msg = "```sql\nDELETE FROM old_records;\n```\n需要我帮你查询一下 old_records 还剩多少条吗?";
expect(looksLikeWriteSqlProposal(msg)).toBe(false);
});
it("rejects en proposal: code block present but last line asks about query", () => {
const msg = "```sql\nDELETE FROM old_records;\n```\nShould I query how many records remain?";
expect(looksLikeWriteSqlProposal(msg)).toBe(false);
});
});
// ── shouldGrantWriteSqlOnShortAffirmative ──────────────────────────────────
describe("shouldGrantWriteSqlOnShortAffirmative (manual-typing confirmation)", () => {
const writeProposal = {
role: "assistant" as const,
content: "已经分析了表结构。\n需要我执行这条 CREATE TABLE users 语句吗?",
};
const insertProposal = {
role: "assistant" as const,
content: "I understand the schema.\nShould I run this INSERT for you?",
};
const writeProposalEn = {
role: "assistant" as const,
content: "Should I execute this CREATE TABLE users?",
};
// Prompt-mirrored generic confirmation with SQL code block.
const genericZhProposal = {
role: "assistant" as const,
content: "以下是建表 SQL:\n```sql\nCREATE TABLE users (id INT, name TEXT);\n```\n需要我执行这条 SQL 吗?",
};
function params(overrides: Partial<WriteSqlGrantParams> = {}): WriteSqlGrantParams {
return {
mode: "agent",
alreadyGranted: false,
isProduction: false,
userText: "可以",
messages: [{ role: "user", content: "帮我创建表" }, writeProposal],
...overrides,
};
}
it("grants when user types 可以 and assistant asked to execute a write", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params())).toBe(true);
});
it("grants when user types yes and assistant asked to run INSERT", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params({ userText: "yes", messages: [{ role: "user", content: "帮我插入" }, insertProposal] }))).toBe(true);
});
it("grants when user types go ahead (en)", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params({ userText: "go ahead", messages: [{ role: "user", content: "please insert" }, writeProposalEn] }))).toBe(true);
});
it("grants for generic confirmation phrase when message has SQL code block", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params({ messages: [{ role: "user", content: "创建表" }, genericZhProposal] }))).toBe(true);
});
it("skips contextSummary messages to find the assistant message", () => {
expect(
shouldGrantWriteSqlOnShortAffirmative(
params({
userText: "好",
messages: [{ role: "user", content: "帮我创建" }, { role: "assistant", content: "--- context compaction ---", kind: "contextSummary" }, writeProposal],
}),
),
).toBe(true);
});
it("stops at user messages (does not look past the current turn)", () => {
expect(
shouldGrantWriteSqlOnShortAffirmative(
params({
messages: [{ role: "user", content: "查一下数据" }, { role: "assistant", content: "SELECT * FROM users 的结果是..." }, { role: "user", content: "现在帮我建表" }, writeProposal],
}),
),
).toBe(true);
});
// ── negative — write keyword in earlier line, NOT the proposal line ─────
it("does NOT grant when assistant only explains write SQL without asking to execute", () => {
const explanation = {
role: "assistant" as const,
content: "DELETE 语句会删除所有匹配的数据。当前环境不允许执行写操作,你可以手动在查询器里运行。",
};
expect(containsWriteSql(explanation.content)).toBe(true); // has DELETE
expect(looksLikeActionProposal(explanation.content)).toBe(false); // not a proposal
expect(shouldGrantWriteSqlOnShortAffirmative(params({ messages: [{ role: "user", content: "帮我删除" }, explanation] }))).toBe(false);
});
it("does NOT grant when assistant proposes a non-write action", () => {
const readProposal = {
role: "assistant" as const,
content: "需要我查询一下 users 表的结构吗?",
};
expect(looksLikeActionProposal(readProposal.content)).toBe(true);
expect(looksLikeWriteSqlProposal(readProposal.content)).toBe(false);
expect(shouldGrantWriteSqlOnShortAffirmative(params({ userText: "好", messages: [{ role: "user", content: "看看有哪些表" }, readProposal] }))).toBe(false);
});
// ── guard conditions ────────────────────────────────────────────────────
it("does NOT grant in ask mode", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params({ mode: "ask" }))).toBe(false);
});
it("does NOT grant when production DB is active", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params({ isProduction: true }))).toBe(false);
});
it("does NOT grant when already granted (avoid overwrite)", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params({ alreadyGranted: true }))).toBe(false);
});
it("does NOT grant when user text is NOT a short affirmative", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params({ userText: "好的,我来看看具体情况再说" }))).toBe(false);
});
});
// ── containsWriteSql regex ──────────────────────────────────────────────────
describe("containsWriteSql regex", () => {
it("matches INSERT", () => expect(containsWriteSql("INSERT INTO users VALUES (1)")).toBe(true));
it("matches UPDATE", () => expect(containsWriteSql("UPDATE users SET name='x'")).toBe(true));
it("matches DELETE", () => expect(containsWriteSql("DELETE FROM users")).toBe(true));
it("matches CREATE TABLE", () => expect(containsWriteSql("CREATE TABLE t (id INT)")).toBe(true));
it("matches ALTER TABLE", () => expect(containsWriteSql("ALTER TABLE users ADD COLUMN x INT")).toBe(true));
it("matches DROP TABLE", () => expect(containsWriteSql("DROP TABLE users")).toBe(true));
it("matches TRUNCATE", () => expect(containsWriteSql("TRUNCATE TABLE users")).toBe(true));
it("matches RENAME", () => expect(containsWriteSql("RENAME TABLE old TO new")).toBe(true));
it("matches GRANT", () => expect(containsWriteSql("GRANT SELECT ON t TO u")).toBe(true));
it("matches REVOKE", () => expect(containsWriteSql("REVOKE SELECT ON t FROM u")).toBe(true));
it("matches REPLACE", () => expect(containsWriteSql("REPLACE INTO users VALUES (1)")).toBe(true));
it("matches MERGE", () => expect(containsWriteSql("MERGE INTO t USING s ON ...")).toBe(true));
it("case-insensitive: insert", () => expect(containsWriteSql("insert into users")).toBe(true));
it("does NOT match SELECT", () => expect(containsWriteSql("SELECT * FROM users")).toBe(false));
it("does NOT match SELECT only content", () => expect(containsWriteSql("需要我帮你查一下数据?")).toBe(false));
it("does NOT match empty", () => expect(containsWriteSql("")).toBe(false));
});
// ── Button-confirmation flow (looksLikeActionProposal) ──────────────────────
describe("button-confirmation write-SQL flow (looksLikeActionProposal path)", () => {
it("detects a write-SQL action proposal (需要我执行 CREATE...?)", () => {
const content = "已经分析了表结构。\n需要我执行 CREATE TABLE users 吗?";
expect(looksLikeActionProposal(content)).toBe(true);
expect(looksLikeWriteSqlProposal(content)).toBe(true);
});
it("detects a write-SQL action proposal (Should I run this INSERT?)", () => {
const content = "I think we need to add a row.\nShould I run this INSERT?";
expect(looksLikeActionProposal(content)).toBe(true);
expect(looksLikeWriteSqlProposal(content)).toBe(true);
});
it("detects generic confirmation phrase when message body has SQL code block", () => {
const content = "以下是建表 SQL:\n```sql\nCREATE TABLE users (id INT, name TEXT);\n```\n需要我执行这条 SQL 吗?";
expect(looksLikeActionProposal(content)).toBe(true);
expect(looksLikeWriteSqlProposal(content)).toBe(true);
});
});
// ── executeAndExplain skill prompt ──────────────────────────────────────────
describe("executeAndExplain write-confirmation prompt", () => {
it("en: instructs to ask for confirmation instead of blocking", () => {
const skill = aiSkillForAction("executeAndExplain");
const enRule = skill.systemRules.en[0];
expect(enRule).toContain("ask the user for explicit confirmation");
expect(enRule).toContain("Do not execute writes without user confirmation");
expect(enRule).not.toContain("do not execute");
});
it("zh: instructs to ask for confirmation instead of blocking", () => {
const skill = aiSkillForAction("executeAndExplain");
const zhRule = skill.systemRules.zh[0];
expect(zhRule).toContain("明确询问用户是否确认执行");
expect(zhRule).toContain("禁止不经确认直接执行写入");
expect(zhRule).not.toContain("不要执行");
});
});
// ── extractFirstSqlCodeBlock (SQL binding) ─────────────────────────────────
describe("extractFirstSqlCodeBlock", () => {
it("extracts SQL from a fenced code block", () => {
const content = "以下是建表 SQL:\n```sql\nCREATE TABLE users (id INT);\n```\n需要我执行这条吗?";
expect(extractFirstSqlCodeBlock(content)).toBe("CREATE TABLE users (id INT);");
});
it("returns undefined when there is no code block", () => {
const content = "需要我执行 CREATE TABLE users 吗?";
expect(extractFirstSqlCodeBlock(content)).toBeUndefined();
});
it("returns undefined when only an inline code span is present", () => {
const content = "需要我执行 `CREATE TABLE users` 吗?";
expect(extractFirstSqlCodeBlock(content)).toBeUndefined();
});
it("returns undefined for empty content", () => {
expect(extractFirstSqlCodeBlock("")).toBeUndefined();
});
it("extracts SQL from a code block without language tag", () => {
const content = "Here is the SQL:\n```\nDROP TABLE old;\n```\nShould I execute it?";
expect(extractFirstSqlCodeBlock(content)).toBe("DROP TABLE old;");
});
});
// ── countSqlCodeBlocks ──────────────────────────────────────────────────────
describe("countSqlCodeBlocks", () => {
it("counts zero when there are no code blocks", () => {
expect(countSqlCodeBlocks("需要我执行 CREATE TABLE users 吗?")).toBe(0);
});
it("counts one code block", () => {
expect(countSqlCodeBlocks("```sql\nSELECT 1;\n```")).toBe(1);
});
it("counts two code blocks", () => {
const content = "```sql\nDELETE FROM old;\n```\n```sql\nDELETE FROM new;\n```";
expect(countSqlCodeBlocks(content)).toBe(2);
});
it("counts three code blocks", () => {
const content = "```sql\nA\n```\n```\nB\n```\n```sql\nC\n```";
expect(countSqlCodeBlocks(content)).toBe(3);
});
it("counts zero for inline code spans", () => {
expect(countSqlCodeBlocks("Use `SELECT 1` to test.")).toBe(0);
});
});
// ── extractSingleSqlCodeBlock (regression: ambiguous multi-block proposals) ──
describe("extractSingleSqlCodeBlock (multi-block safety)", () => {
it("extracts SQL when exactly one code block exists", () => {
const content = "以下是建表 SQL:\n```sql\nCREATE TABLE users (id INT);\n```\n需要我执行这条吗?";
expect(extractSingleSqlCodeBlock(content)).toBe("CREATE TABLE users (id INT);");
});
it("returns undefined when there are zero code blocks", () => {
const content = "需要我执行 CREATE TABLE users 吗?";
expect(extractSingleSqlCodeBlock(content)).toBeUndefined();
});
it("returns undefined when there are multiple code blocks (fail-closed)", () => {
// Regression: a message with "do not execute" DELETE first, then a
// recommended DELETE second. extractFirstSqlCodeBlock would return the
// first (dangerous) block, but extractSingleSqlCodeBlock rightly refuses.
const content = ["I found two approaches:", "```sql", "DELETE FROM users; -- do NOT execute this", "```", "```sql", "DELETE FROM users WHERE id = 7; -- recommended", "```", "Should I execute the recommended SQL?"].join("\n");
expect(extractFirstSqlCodeBlock(content)).toBe("DELETE FROM users; -- do NOT execute this");
expect(extractSingleSqlCodeBlock(content)).toBeUndefined();
});
it("returns undefined when multiple blocks have different language tags", () => {
const content = "```sql\nSELECT 1;\n```\n```postgresql\nSELECT 2;\n```";
expect(extractSingleSqlCodeBlock(content)).toBeUndefined();
});
it("returns undefined when multiple blocks have no language tags", () => {
const content = "```\nblock A\n```\n```\nblock B\n```";
expect(extractSingleSqlCodeBlock(content)).toBeUndefined();
});
it("counts code blocks case-insensitively for language tags", () => {
const content = "```SQL\nDELETE FROM a;\n```\n```Sql\nDELETE FROM b;\n```";
expect(countSqlCodeBlocks(content)).toBe(2);
expect(extractSingleSqlCodeBlock(content)).toBeUndefined();
});
});
+10 -2
View File
@@ -95,6 +95,11 @@ export interface AiRequestInput {
instruction: string;
context: AiContext;
allowWriteSql?: boolean;
/** When allowWriteSql is true, the specific write SQL the user confirmed. */
confirmedWriteSql?: string;
/** Connection/database snapshot at confirmation time; verified at backend. */
confirmedConnectionId?: string;
confirmedDatabase?: string;
}
export interface CustomPromptContext {
@@ -187,6 +192,9 @@ export async function runAgentStream(input: AiRequestInput, history: api.AiMessa
onEvent,
input.mode || "ask",
input.allowWriteSql || false,
input.confirmedWriteSql,
input.confirmedConnectionId,
input.confirmedDatabase,
);
}
@@ -348,8 +356,8 @@ function buildModePromptLines(mode: AiAssistantMode, isZh: boolean): string[] {
? "用户提出数据查询意图时,必须调用 execute_query 工具执行 SQL,不要只输出 SQL 文本后停止。先用 list_tables/get_columns 了解 schema,再调用 execute_query 获取真实结果,最后基于结果回答用户。"
: "When the user expresses a data query intent, you MUST call the execute_query tool to run the SQL — do NOT just output SQL text and stop. Use list_tables/get_columns to understand the schema first, then call execute_query to get real results, then answer based on the actual data.",
isZh
? "只有 SELECT、WITH、SHOW、DESCRIBE、EXPLAIN 可以通过 execute_query 执行。如果用户要求写入操作,先解释原因,不要执行。"
: "Only SELECT, WITH, SHOW, DESCRIBE, EXPLAIN can be executed via execute_query. If the user requests a write operation, explain why it is blocked instead of executing.",
? "当用户要求写入操作(INSERT/UPDATE/DELETE/CREATE/ALTER/DROP/TRUNCATE 等)时,先在一个 ```sql 代码块中给出精确的写 SQL,再在回复末尾用问句明确询问用户是否确认执行(例如'需要我执行这条 CREATE TABLE 语句吗?')。待用户明确确认后再调用 execute_query,并原样使用该代码块中的 SQL,不得改写、重新格式化或补充语句。禁止不经确认直接执行写入。"
: "When the user requests a write operation (INSERT/UPDATE/DELETE/CREATE/ALTER/DROP/TRUNCATE, etc.), first put the exact proposed write SQL in one ```sql code block, then ask for explicit confirmation at the end of your reply with a question that names the specific operation (e.g., 'Should I execute this CREATE TABLE?'). Only call execute_query for writes after the user explicitly confirms, and use the exact SQL from that code block without rewriting, reformatting, or adding statements. Never execute writes without confirmation.",
isZh ? "如果安全执行条件不满足,先说明原因,再给只读预览或澄清问题。" : "If safe execution requirements are not met, explain why first, then provide a read-only preview or a clarifying question.",
];
}
+136 -1
View File
@@ -22,7 +22,7 @@ function stripOuterPunctuation(input: string): string {
}
/** Returns the last non-empty line/sentence-ish chunk of the assistant message. */
function lastNonEmptyLine(content: string): string {
export function lastNonEmptyLine(content: string): string {
const lines = content.split(/\r?\n/);
for (let i = lines.length - 1; i >= 0; i--) {
const trimmed = lines[i].trim();
@@ -166,3 +166,138 @@ export function isShortNegative(content: string): boolean {
const all = [...ZH_NEGATIVE, ...EN_NEGATIVE];
return all.some((re) => re.test(cleaned));
}
/**
* Regular expression matching common SQL write/DDL keywords.
* Shared between AiAssistant.vue and tests so the regex stays in one place.
*/
export const WRITE_SQL_KEYWORD_RE = /\b(insert|update|delete|replace|merge|create|alter|drop|truncate|rename|grant|revoke)\b/i;
/**
* Returns true when the text contains SQL write or DDL keywords.
*/
export function containsWriteSql(text: string): boolean {
return WRITE_SQL_KEYWORD_RE.test(text);
}
/**
* Returns true when the assistant message ends with an action proposal AND
* the message as a whole contains write/DDL SQL keywords.
*
* Check order:
* 1. Last non-empty line must be an action proposal (question mark + ask
* phrase + action verb).
* 2. The *entire message* (not just the last line) must contain at least
* one write/DDL keyword (INSERT, DELETE, CREATE, etc.).
*
* This two-step check handles the common pattern where the model outputs
* a SQL code block followed by a generic confirmation question like
* "需要我执行这条 SQL 吗?" — the write keywords live in the code block,
* not in the last line.
*
* **Safety rule**: the last line (the proposal itself) MUST explicitly
* reference the write SQL or the write operation. A message where the
* last line asks about a read operation but a write keyword appears
* elsewhere is NOT treated as a write-SQL proposal — the assistant is
* offering a read, not a write. Without this rule, confirming "need me
* to query the remaining records?" could inadvertently grant write
* permission because DELETE appeared earlier in the message.
*/
export function looksLikeWriteSqlProposal(content: string): boolean {
if (!content) return false;
const lastLine = lastNonEmptyLine(content);
if (!lastLine) return false;
// Step 1: last line must be an action proposal (ask + action + question mark).
if (!/[??]\s*$/.test(lastLine)) return false;
const isZh = containsChinese(lastLine);
const askPhrases = isZh ? ZH_ASK_PHRASES : EN_ASK_PHRASES;
const actionPhrases = isZh ? ZH_ACTION_PHRASES : EN_ACTION_PHRASES;
if (!askPhrases.some((re) => re.test(lastLine))) return false;
if (!actionPhrases.some((re) => re.test(lastLine))) return false;
// Step 2: the full message must contain write/DDL SQL keywords somewhere
// (typically in a code block or earlier sentence).
if (!containsWriteSql(content)) return false;
// Safety check: the last line must explicitly reference the write SQL or
// the write/DDL operation itself. A last line that asks about a read
// ("查询"/"query") but happens to have a write keyword elsewhere in the
// message is NOT a write-SQL proposal — it is a read proposal.
// We check for three signals:
// a) The last line contains a write/DDL keyword directly, OR
// b) The last line contains a "this SQL" / "这条 SQL" reference that
// points back to the code block with the write SQL, OR
// c) The content has a SQL code block AND the last line mentions
// SQL / 执行 without specifying a different action (e.g. 查询).
return lastLineMentionsWriteSql(lastLine, isZh, content);
}
/**
* Returns true when the proposal line explicitly references a write/DDL
* SQL operation (not a read-only action that happens to coexist with a
* write keyword elsewhere in the message).
*/
function lastLineMentionsWriteSql(lastLine: string, isZh: boolean, fullContent: string): boolean {
// Signal (a): last line contains a write/DDL keyword itself.
if (containsWriteSql(lastLine)) return true;
// Signal (b): last line references "this SQL" / "这条 SQL" / "这条语句"
// pointing back to a code block that contains the write SQL.
if (isZh) {
if (/这条\s*SQL|这条\s*语句|这个\s*SQL|这段\s*SQL|上述\s*SQL|以上\s*SQL/.test(lastLine)) return true;
} else {
if (/this\s+SQL|the\s+SQL|this\s+statement|the\s+statement|this\s+query|the\s+query|above\s+SQL/.test(lastLine)) return true;
}
// Signal (c): message has a SQL code block AND the last line mentions
// 执行/execute/run without specifying a different action like 查询/query.
const hasCodeBlock = /```sql|```mysql|```postgresql|```sqlite|```tsql|```clickhouse|```\s*\n/.test(fullContent);
if (hasCodeBlock) {
if (isZh) {
// Must mention 执行 or 运行 (execute/write-like), NOT 查询/查看 (read-like).
if (/(?:执行|运行)/.test(lastLine) && !/(?:查询|查看|看看|看一下|读取)/.test(lastLine)) return true;
} else {
if (/\b(?:execute|run)\b/i.test(lastLine) && !/\b(?:query|read|fetch|retrieve|list|inspect|check|sample)\b/i.test(lastLine)) return true;
}
}
return false;
}
/** Parameters for {@link shouldGrantWriteSqlOnShortAffirmative}. */
export interface WriteSqlGrantParams {
mode: string;
/** True when allowWriteSqlForNextRun has already been set, e.g. by the button path. */
alreadyGranted: boolean;
isProduction: boolean;
userText: string;
/**
* Conversation history BEFORE the current user text was pushed.
* The function scans backward from the last message, skipping
* contextSummary entries, and stops at the first user message.
*/
messages: Array<{ role: "user" | "assistant"; content: string; kind?: "contextSummary" }>;
}
/**
* Pure-function extraction of the manual-confirmation gating logic used in
* AiAssistant.vue send(). Returns true when the user just typed a short
* affirmative reply (e.g. "可以"/"go ahead") and the most recent assistant
* message before the user message is a write-SQL action proposal.
*
* Shared between the component and its unit tests so the two cannot drift.
*/
export function shouldGrantWriteSqlOnShortAffirmative(params: WriteSqlGrantParams): boolean {
if (params.mode !== "agent" || params.isProduction || params.alreadyGranted) return false;
if (!isShortAffirmative(params.userText)) return false;
for (let i = params.messages.length - 1; i >= 0; i--) {
const msg = params.messages[i];
if (msg.kind === "contextSummary") continue;
if (msg.role === "assistant" && msg.content && looksLikeWriteSqlProposal(msg.content)) return true;
if (msg.role === "user") return false;
}
return false;
}
+8 -2
View File
@@ -237,8 +237,14 @@ export const AI_SKILL_DEFINITIONS: AiSkillDefinition[] = [
zh: "执行当前的 SQL 并解释结果。先调用 execute_query 运行当前 SQL,再基于真实结果解释查询含义、数据特征和值得注意的点。",
},
systemRules: {
en: ["Only SELECT/WITH/SHOW/DESCRIBE/EXPLAIN can be executed. If the current SQL is a write operation, do not execute; explain its impact instead.", "Base explanations on real execution results; do not speculate about data."],
zh: ["只有 SELECT/WITH/SHOW/DESCRIBE/EXPLAIN 可执行。如果当前 SQL 是写操作,不要执行,改为解释其影响。", "解释要基于真实执行结果,不要凭空推测数据。"],
en: [
"If the current SQL is a write operation, first put its exact text in one ```sql code block and ask the user for explicit confirmation (e.g., 'Should I execute this SQL?') instead of executing immediately. After confirmation, execute that code-block SQL verbatim without rewriting, reformatting, or adding statements. Do not execute writes without user confirmation.",
"Base explanations on real execution results; do not speculate about data.",
],
zh: [
"如果当前 SQL 是写操作,先在一个 ```sql 代码块中给出其精确文本,并在回复末尾明确询问用户是否确认执行(例如'需要我执行这条 SQL 吗?'),不要直接执行。确认后必须原样执行该代码块中的 SQL,不得改写、重新格式化或补充语句。禁止不经确认直接执行写入。",
"解释要基于真实执行结果,不要凭空推测数据。",
],
},
outputContract: {
en: ["Output format: lead with an execution-result summary, then step through the key data and its meaning."],
@@ -123,3 +123,23 @@ export function extractFirstSqlCodeBlock(content: string): string | undefined {
const sql = match?.[1]?.trim();
return sql || undefined;
}
/**
* Count SQL code blocks in content. Used to reject ambiguous multi-block
* proposals — when a message contains more than one SQL code block, we
* cannot determine which one the user intended to confirm.
*/
export function countSqlCodeBlocks(content: string): number {
const matches = content.match(/```(?:sql|mysql|postgresql|sqlite|tsql|clickhouse)?\s*\n[\s\S]*?```/gi);
return matches ? matches.length : 0;
}
/**
* Extract a single SQL code block from content. Returns undefined when the
* message has zero or more than one code block — only an unambiguous
* single-block proposal yields a confirmed SQL binding.
*/
export function extractSingleSqlCodeBlock(content: string): string | undefined {
if (countSqlCodeBlocks(content) !== 1) return undefined;
return extractFirstSqlCodeBlock(content);
}
+15 -2
View File
@@ -1137,11 +1137,24 @@ function isAgentEvent(v: unknown): v is import("@/lib/backend/tauri").AgentEvent
return typeof v === "object" && v !== null && "type" in v && typeof (v as Record<string, unknown>).type === "string";
}
export async function aiAgentStream(sessionId: string, request: AiCompletionRequest, connectionId: string, database: string, dbType: string, onEvent: (event: import("@/lib/backend/tauri").AgentEvent) => void, mode?: string, allowWriteSql = false, signal?: AbortSignal): Promise<string> {
export async function aiAgentStream(
sessionId: string,
request: AiCompletionRequest,
connectionId: string,
database: string,
dbType: string,
onEvent: (event: import("@/lib/backend/tauri").AgentEvent) => void,
mode?: string,
allowWriteSql = false,
confirmedWriteSql?: string,
confirmedConnectionId?: string,
confirmedDatabase?: string,
signal?: AbortSignal,
): Promise<string> {
const res = await fetch(apiUrl("/api/ai/agent-stream"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, request, connectionId, database, dbType, mode: mode || "ask", allowWriteSql }),
body: JSON.stringify({ sessionId, request, connectionId, database, dbType, mode: mode || "ask", allowWriteSql, confirmedWriteSql, confirmedConnectionId, confirmedDatabase }),
signal,
});
if (!res.ok) throw new Error(await res.text());
+15 -2
View File
@@ -390,7 +390,20 @@ export type AgentEvent =
| { type: "context_compacted"; summary: string; summary_tokens: number; compacted_messages: number; estimated_before: number; estimated_after: number }
| { type: "error"; message: string };
export async function aiAgentStream(sessionId: string, request: AiCompletionRequest, connectionId: string, database: string, dbType: string, onEvent: (event: AgentEvent) => void, mode?: string, allowWriteSql = false, _signal?: AbortSignal): Promise<string> {
export async function aiAgentStream(
sessionId: string,
request: AiCompletionRequest,
connectionId: string,
database: string,
dbType: string,
onEvent: (event: AgentEvent) => void,
mode?: string,
allowWriteSql = false,
confirmedWriteSql?: string,
confirmedConnectionId?: string,
confirmedDatabase?: string,
_signal?: AbortSignal,
): Promise<string> {
const unlisten: UnlistenFn = await listen<AgentEvent>("ai-agent-event", (event) => {
onEvent(event.payload);
if (event.payload.type === "agent_end" || event.payload.type === "error") {
@@ -398,7 +411,7 @@ export async function aiAgentStream(sessionId: string, request: AiCompletionRequ
}
});
try {
return await invoke("ai_agent_stream", { sessionId, request, connectionId, database, dbType, mode, allowWriteSql });
return await invoke("ai_agent_stream", { sessionId, request, connectionId, database, dbType, mode, allowWriteSql, confirmedWriteSql, confirmedConnectionId, confirmedDatabase });
} catch (e) {
unlisten();
throw e;