fix(ai): harden write SQL confirmation flow
This commit is contained in:
1 parent
1e72d28ed3
commit
cb9d0f7cbf
20 files changed
+1258
-51
No files matched your search
+26
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user