fix(ai): append generated SQL without duplicates

This commit is contained in:
0xTyson
2026-08-30 12:25:54 +08:00
committed by GitHub
parent 143be57d41
commit 9491ab08df
5 changed files with 83 additions and 12 deletions
+9 -5
View File
@@ -102,7 +102,7 @@ import { createTabSwitcherKeyboardController } from "@/lib/tabs/tabSwitcherKeybo
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities";
import { classifyAiSqlExecution } from "@/lib/ai/aiSqlExecutionPolicy";
import { buildAppendedEditorSql } from "@/lib/ai/aiSqlAppend";
import { buildAppendedEditorSql, buildDeduplicatedAppendedEditorSql } from "@/lib/ai/aiSqlAppend";
import { assessProductionSql } from "@/lib/database/productionSafety";
import { normalizeSqlExecutionTarget, sqlExecutionTargetCapabilities } from "@/lib/database/sqlExecutionTargetCapabilities";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
@@ -2241,10 +2241,12 @@ function routeAiRedisCommand(command: string, execute: boolean): boolean {
return true;
}
function onAiReplaceSql(sql: string) {
function onAiAppendSql(sql: string) {
if (routeAiRedisCommand(sql, false)) return;
const tabId = ensureQueryTab();
queryStore.updateSql(tabId, sql);
const currentSql = queryStore.tabs.find((tab) => tab.id === tabId)?.sql ?? "";
const appendedSql = buildDeduplicatedAppendedEditorSql(currentSql, sql);
if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql);
}
function runAiGeneratedSql(sql: string) {
@@ -2255,7 +2257,9 @@ function runAiGeneratedSql(sql: string) {
function onAiExecuteSql(sql: string) {
if (routeAiRedisCommand(sql, true)) return;
const tabId = ensureQueryTab();
queryStore.updateSql(tabId, buildAppendedEditorSql(activeTab.value?.sql || "", sql));
const currentSql = queryStore.tabs.find((tab) => tab.id === tabId)?.sql ?? "";
const appendedSql = buildDeduplicatedAppendedEditorSql(currentSql, sql);
if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql);
runAiGeneratedSql(sql);
}
@@ -3225,7 +3229,7 @@ onUnmounted(() => {
:tab="activeTab"
:connection="activeConnection"
:maximized="isAiPanelMaximized"
@replace-sql="onAiReplaceSql"
@append-sql="onAiAppendSql"
@execute-sql="onAiExecuteSql"
@temp-run-sql="onAiTempRunSql"
@request-auto-execute-sql="onAiRequestAutoExecuteSql"
@@ -24,6 +24,7 @@ import {
GitBranch,
HelpCircle,
History,
ArrowDownToLine,
Loader2,
Maximize2,
MessageSquarePlus,
@@ -31,7 +32,6 @@ import {
Pencil,
Plus,
RefreshCw,
Replace,
Server,
ShieldCheck,
Table2,
@@ -242,7 +242,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
replaceSql: [sql: string];
appendSql: [sql: string];
executeSql: [sql: string];
tempRunSql: [sql: string];
requestAutoExecuteSql: [sql: string];
@@ -1197,7 +1197,7 @@ function sendProposalReply(positive: boolean) {
const isWriteConfirmation = isActionableWriteProposalMessage(target);
if (positive && productionContext.value.active && (target.kind === "writeSqlConfirmation" || looksLikeWriteSqlProposal(target.content))) {
const sql = extractFirstSqlCodeBlock(target.content);
if (sql) emit("replaceSql", sql);
if (sql) emit("appendSql", sql);
toast(t("production.aiReviewRequired"), 5000);
return;
}
@@ -3477,7 +3477,7 @@ function applySql(code: string) {
emit("insertRedisCommand", code);
return;
}
emit("replaceSql", code);
emit("appendSql", code);
}
function executeSql(code: string) {
@@ -4604,7 +4604,7 @@ async function openExternalUrl(url: string) {
<Play class="h-3.5 w-3.5" />
</button>
<button v-if="!seg.pending && (seg.isSql || isRedisConnection)" class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.apply')" @click="applySql(seg.content)">
<Replace class="h-3.5 w-3.5" />
<ArrowDownToLine class="h-3.5 w-3.5" />
</button>
<button
class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
@@ -19,14 +19,23 @@ describe("AI Redis console routing", () => {
});
it("keeps the existing SQL editor and execution behavior for non-Redis connections", () => {
expect(aiAssistantSource).toContain('emit("replaceSql", code)');
expect(aiAssistantSource).toContain('emit("appendSql", code)');
expect(aiAssistantSource).toContain('emit("executeSql", code)');
expect(aiAssistantSource).toContain('emit("tempRunSql", code)');
expect(appSource).toContain("const tabId = ensureQueryTab();");
expect(appSource).toContain("buildDeduplicatedAppendedEditorSql(currentSql, sql)");
expect(appSource).toContain('buildAppendedEditorSql(activeTab.value?.sql || "", sql)');
expect(appSource).toContain("const decision = classifyAiSqlExecution(sql, activeConnection.value);");
});
it("deduplicates immediate-run editor writes without skipping execution", () => {
const handler = appSource.match(/function onAiExecuteSql\(sql: string\) \{[\s\S]*?\n\}/)?.[0] ?? "";
expect(handler).toContain("buildDeduplicatedAppendedEditorSql(currentSql, sql)");
expect(handler).toContain("if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql);");
expect(handler).toContain("runAiGeneratedSql(sql);");
expect(handler).not.toContain("buildAppendedEditorSql(");
});
it("uses the console safety path and rejects unavailable command input", () => {
expect(classifyRedisCommandSafety("CONFIG SET requirepass secret")).toBe("blocked");
expect(classifyRedisCommandSafety("FLUSHDB")).toBe("confirm");
+27
View File
@@ -17,3 +17,30 @@ export function buildAppendedEditorSql(currentEditorSql: string, newSql: string)
return `${currentEditorSql}${separator}${newSql}`;
}
/**
* Append AI-generated SQL unless the exact text already exists as a standalone
* editor block. AI appends use a blank line as their block separator, which
* avoids treating a matching fragment inside a larger statement as a duplicate.
*/
export function buildDeduplicatedAppendedEditorSql(currentEditorSql: string, newSql: string): string {
if (!newSql || containsStandaloneSqlBlock(currentEditorSql, newSql)) return currentEditorSql;
return buildAppendedEditorSql(currentEditorSql, newSql);
}
function containsStandaloneSqlBlock(editorSql: string, sql: string): boolean {
let from = 0;
while (from <= editorSql.length - sql.length) {
const index = editorSql.indexOf(sql, from);
if (index < 0) return false;
const before = editorSql.slice(0, index);
const after = editorSql.slice(index + sql.length);
const startsAtBlockBoundary = index === 0 || /(?:\r?\n){2}$/.test(before);
const endsAtBlockBoundary = after.length === 0 || /^(?:\r?\n){2}/.test(after);
if (startsAtBlockBoundary && endsAtBlockBoundary) return true;
from = index + 1;
}
return false;
}
+32 -1
View File
@@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildAppendedEditorSql } from "../../apps/desktop/src/lib/ai/aiSqlAppend.ts";
import { buildAppendedEditorSql, buildDeduplicatedAppendedEditorSql } from "../../apps/desktop/src/lib/ai/aiSqlAppend.ts";
test("buildAppendedEditorSql returns newSql unchanged when editor is empty", () => {
assert.equal(buildAppendedEditorSql("", "SELECT 1"), "SELECT 1");
@@ -33,3 +33,34 @@ test("buildAppendedEditorSql preserves whitespace-only editor content", () => {
test("buildAppendedEditorSql preserves unfinished SQL", () => {
assert.equal(buildAppendedEditorSql("SELECT * FROM", "SELECT * FROM users"), "SELECT * FROM\n\nSELECT * FROM users");
});
test("buildDeduplicatedAppendedEditorSql does not append when the editor exactly matches", () => {
assert.equal(buildDeduplicatedAppendedEditorSql("SELECT 1", "SELECT 1"), "SELECT 1");
});
test("buildDeduplicatedAppendedEditorSql does not append a previously appended SQL block", () => {
const editorSql = "SELECT 1\n\nSELECT 2";
assert.equal(buildDeduplicatedAppendedEditorSql(editorSql, "SELECT 2"), editorSql);
});
test("buildDeduplicatedAppendedEditorSql detects a matching block in the middle", () => {
const editorSql = "SELECT 1\n\nSELECT 2\n\nSELECT 3";
assert.equal(buildDeduplicatedAppendedEditorSql(editorSql, "SELECT 2"), editorSql);
});
test("buildDeduplicatedAppendedEditorSql supports CRLF block separators", () => {
const editorSql = "SELECT 1\r\n\r\nSELECT 2";
assert.equal(buildDeduplicatedAppendedEditorSql(editorSql, "SELECT 2"), editorSql);
});
test("buildDeduplicatedAppendedEditorSql does not confuse a matching statement fragment with a standalone block", () => {
assert.equal(buildDeduplicatedAppendedEditorSql("WITH value AS (\nSELECT 1\n)", "SELECT 1"), "WITH value AS (\nSELECT 1\n)\n\nSELECT 1");
});
test("buildDeduplicatedAppendedEditorSql appends SQL that is only a substring of an existing block", () => {
assert.equal(buildDeduplicatedAppendedEditorSql("SELECT 10", "SELECT 1"), "SELECT 10\n\nSELECT 1");
});
test("buildDeduplicatedAppendedEditorSql ignores an empty append", () => {
assert.equal(buildDeduplicatedAppendedEditorSql("SELECT 1", ""), "SELECT 1");
});