mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(ai): append generated SQL without duplicates
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user