feat(ai): bind each conversation to its own connection

This commit is contained in:
Abeautifulsnow
2026-09-23 17:56:05 +08:00
committed by GitHub
parent 47e7f56b14
commit c373f322d2
35 changed files with 1584 additions and 272 deletions
+112 -59
View File
@@ -157,6 +157,7 @@ import { countActiveUpdateBlockingTasks } from "@/lib/app/appUpdateTaskGuard";
import { initSavedSqlEditorPositions } from "@/lib/app/savedSqlEditorPosition";
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
import { objectBrowserTablesToAiTreeNodes } from "@/lib/ai/objectBrowserToAiTargets";
import type { AiConversationBinding } from "@/lib/ai/aiConversationBinding";
import { isSchemaAware, isSingleDatabase, supportsConnectionQueryActions, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport";
import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { canFormatSqlForDatabaseType, formatSqlForEditing, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
@@ -200,7 +201,9 @@ type AiAssistantHandle = {
openPluginConversation: (request: AiPluginConversationRequest) => void;
triggerAction: (action: AiAction, instruction?: string) => void;
setPrompt: (text: string) => void;
addTableMention: (target: { schema?: string; table: string }) => void;
addTableMention: (target: { schema?: string; table: string }, binding?: AiConversationBinding) => void;
/** Retarget the conversation on its own, for entries that add no mention. */
bindConversation: (binding: AiConversationBinding) => Promise<void>;
clearContextReferences: () => void;
focusSearch: () => boolean;
/** Opens a conversation by id (used by the background-run toast, §9). */
@@ -1745,7 +1748,6 @@ async function addToAi(nodesInput: TreeNode | TreeNode[]) {
try {
await connectionStore.ensureConnected(node.connectionId);
if (requestId !== addToAiRequestId) return;
connectionStore.activeConnectionId = node.connectionId;
let target: { database: string; schema?: string; catalog?: string } | null = null;
if (node.type === "connection") {
@@ -1766,22 +1768,19 @@ async function addToAi(nodesInput: TreeNode | TreeNode[]) {
}
if (!target) return;
const currentTab = activeTab.value;
const contextChanged = currentTab?.connectionId !== node.connectionId || currentTab?.database !== target.database || (currentTab?.schema || "") !== (target.schema || "") || (currentTab?.catalog || "") !== (target.catalog || "");
const contextTab = queryStore.tabs.find((tab) => tab.connectionId === node.connectionId && tab.database === target.database && (tab.schema || "") === (target.schema || "") && (tab.catalog || "") === (target.catalog || ""));
if (contextTab) {
queryStore.switchTab(contextTab.id);
} else {
queryStore.createTab(node.connectionId, target.database, undefined, "query", target.schema, undefined, target.catalog);
}
// The *conversation* is retargeted, not the editor: asking about a table must
// not move the workspace's active connection or steal/create a tab (#9902).
// The previous connection's mentions are cleared inside applyExternalBinding().
const binding: AiConversationBinding = { connectionId: node.connectionId, database: target.database, schema: target.schema };
const tableMentions = nodes.filter((entry) => entry.type === "table" && !!entry.label).map((entry) => ({ schema: entry.schema, table: entry.label }));
openRightSidebarPanel("ai");
invokeWhenAiReady((handle) => {
if (contextChanged) handle.clearContextReferences();
for (const mention of tableMentions) handle.addTableMention(mention);
// Applied independently of the mentions: "Ask AI" on a *connection* or
// *database* node carries a target but adds no table mention, and it still
// has to retarget the conversation (#9902).
void handle.bindConversation(binding);
for (const mention of tableMentions) handle.addTableMention(mention, binding);
});
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
@@ -3105,74 +3104,127 @@ async function locateTabInSidebar(tab: QueryTab) {
await appSidebarRef.value?.locateTabInSidebar(tab);
}
function ensureQueryTab(): string {
const tab = activeTab.value;
if (tab && tab.mode === "query") return tab.id;
const connId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id || "";
const sameConnectionTab = tab?.connectionId === connId ? tab : undefined;
const db = sameConnectionTab?.database || connectionStore.getConfig(connId)?.database || "";
const schema = sameConnectionTab?.schema ?? sameConnectionTab?.objectBrowser?.schema ?? sameConnectionTab?.tableMeta?.schema;
const catalog = sameConnectionTab?.catalog ?? sameConnectionTab?.objectBrowser?.catalog ?? sameConnectionTab?.tableMeta?.catalog;
return queryStore.createTab(connId, db, undefined, "query", schema, undefined, catalog);
/**
* Query tab an AI-initiated action must run in: the one belonging to the
* *requested* connection, created on demand.
*
* Deliberately not `ensureQueryTab()` — that returns whichever query tab happens
* to be active, which is how a conversation bound to one database appended its
* SQL to, and executed it on, another connection's editor (#9902). Focus is left
* alone (`activate: false`), so running a conversation never yanks the user's
* editor away.
*/
function ensureQueryTabForConnection(target: AiConversationBinding): string {
if (!target.connectionId) return "";
const database = target.database || "";
const schema = target.schema || undefined;
// Schema is matched exactly, empty included. Skipping the comparison when the
// target has no schema would reuse a tab on any schema, and execution inherits
// that tab's schema — silently running in the wrong namespace.
const existing = queryStore.tabs.find((tab) => tab.mode === "query" && tab.connectionId === target.connectionId && (tab.database || "") === database && (tab.schema || undefined) === schema);
if (existing) return existing.id;
return queryStore.createTab(target.connectionId, database, undefined, "query", schema, undefined, undefined, { activate: false });
}
function routeAiRedisCommand(command: string, execute: boolean): boolean {
if (activeConnection.value?.db_type !== "redis") return false;
/** Bounded wait for a newly selected Redis console to mount. */
const REDIS_CONSOLE_READY_TIMEOUT_MS = 2000;
const REDIS_CONSOLE_READY_POLL_MS = 50;
// Redis has a dedicated console. Falling through to ensureQueryTab() would
// recreate the original bug by opening a SQL tab for a Redis command.
const routed = execute ? contentAreaRef.value?.executeRedisCommand(command) : contentAreaRef.value?.insertRedisCommand(command);
if (!routed) {
console.warn("[DBX] Redis AI command could not reach the active Redis console");
return true;
}
void routed.then((handled: any) => {
if (!handled) console.warn("[DBX] Redis AI command could not reach the active Redis console");
});
function routeAiRedisCommand(command: string, execute: boolean, target: AiConversationBinding): boolean {
const connection = target.connectionId ? connectionStore.getConfig(target.connectionId) : undefined;
if (connection?.db_type !== "redis") return false;
// Redis has no headless command path — the console *is* the execution vehicle,
// and it renders only for the active tab. We deliberately do NOT switch to the
// bound connection's console: "an AI action never moves the workspace" is the
// whole point of #9902, and an exception here would reintroduce exactly the
// behaviour it removes. The user opens that console and retries instead.
void deliverRedisAiCommand(command, execute, target);
return true;
}
function onAiAppendSql(sql: string) {
if (routeAiRedisCommand(sql, false)) return;
const tabId = ensureQueryTab();
const currentSql = queryStore.tabs.find((tab) => tab.id === tabId)?.sql ?? "";
/**
* Delivers to the bound connection's console when it is already on screen, and
* otherwise refuses with a visible reason.
*
* The bounded wait covers the one benign case — the user just switched to that
* console and it is still mounting. Readiness is polled through a side-effect-free
* probe rather than re-issuing the command: a command that ran but reported
* `false` (e.g. a dangerous command awaiting confirmation) must not execute twice.
*/
async function deliverRedisAiCommand(command: string, execute: boolean, target: AiConversationBinding): Promise<void> {
const deadline = performance.now() + REDIS_CONSOLE_READY_TIMEOUT_MS;
while (!contentAreaRef.value?.isRedisConsoleReady(target)) {
if (performance.now() >= deadline) {
// A console.warn is invisible in a desktop app, and the command the user
// asked for is simply not going to run — say so, and say what to do.
console.warn("[DBX] Redis AI command could not reach the bound Redis console");
toast(t("ai.redisConsoleUnreachable"), 5000);
return;
}
await nextTick();
await new Promise((resolve) => setTimeout(resolve, REDIS_CONSOLE_READY_POLL_MS));
}
const routed = execute ? contentAreaRef.value?.executeRedisCommand(command, target) : contentAreaRef.value?.insertRedisCommand(command, target);
const handled = await routed;
if (!handled) {
console.warn("[DBX] Redis AI command was not accepted by the bound Redis console");
toast(t("ai.redisConsoleUnreachable"), 5000);
}
}
/** Current editor text of the tab an AI action targets. */
function aiTargetTabSql(tabId: string): string {
return queryStore.tabs.find((tab) => tab.id === tabId)?.sql ?? "";
}
function onAiAppendSql(sql: string, target: AiConversationBinding) {
if (routeAiRedisCommand(sql, false, target)) return;
const tabId = ensureQueryTabForConnection(target);
if (!tabId) return;
const currentSql = aiTargetTabSql(tabId);
const appendedSql = buildDeduplicatedAppendedEditorSql(currentSql, sql);
if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql);
}
function runAiGeneratedSql(sql: string, tabId = activeTab.value?.id) {
function runAiGeneratedSql(sql: string, tabId: string) {
if (!tabId) return;
selectedSql.value = "";
nextTick(() => tryExecute(sql, { tabId }));
}
function onAiExecuteSql(sql: string) {
if (routeAiRedisCommand(sql, true)) return;
const tabId = ensureQueryTab();
const currentSql = queryStore.tabs.find((tab) => tab.id === tabId)?.sql ?? "";
function onAiExecuteSql(sql: string, target: AiConversationBinding) {
if (routeAiRedisCommand(sql, true, target)) return;
const tabId = ensureQueryTabForConnection(target);
if (!tabId) return;
const currentSql = aiTargetTabSql(tabId);
const appendedSql = buildDeduplicatedAppendedEditorSql(currentSql, sql);
if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql);
runAiGeneratedSql(sql, tabId);
}
function onAiTempRunSql(sql: string) {
if (routeAiRedisCommand(sql, true)) return;
const tabId = ensureQueryTab();
runAiGeneratedSql(sql, tabId);
function onAiTempRunSql(sql: string, target: AiConversationBinding) {
if (routeAiRedisCommand(sql, true, target)) return;
runAiGeneratedSql(sql, ensureQueryTabForConnection(target));
}
function onAiRequestAutoExecuteSql(sql: string) {
if (routeAiRedisCommand(sql, true)) return;
const tabId = ensureQueryTab();
queryStore.updateSql(tabId, buildAppendedEditorSql(activeTab.value?.sql || "", sql));
function onAiRequestAutoExecuteSql(sql: string, target: AiConversationBinding) {
if (routeAiRedisCommand(sql, true, target)) return;
const tabId = ensureQueryTabForConnection(target);
if (!tabId) return;
// The production gate and the danger classifier must judge the connection the
// SQL will actually run on, not whichever tab is visible (#9902).
const connection = target.connectionId ? connectionStore.getConfig(target.connectionId) : undefined;
queryStore.updateSql(tabId, buildAppendedEditorSql(aiTargetTabSql(tabId), sql));
selectedSql.value = "";
const productionAssessment = assessProductionSql(sql, activeConnection.value, activeTab.value?.database);
const productionAssessment = assessProductionSql(sql, connection, target.database);
if (productionAssessment.active && productionAssessment.isMutation) {
toast(t("production.aiReviewRequired"), 5000);
return;
}
const decision = classifyAiSqlExecution(sql, activeConnection.value);
const decision = classifyAiSqlExecution(sql, connection);
if (decision.action === "block") {
toast(t("ai.autoSqlBlocked"), 5000);
return;
@@ -3187,9 +3239,10 @@ function onAiRequestAutoExecuteSql(sql: string) {
});
}
function onAiOpenExplainPlan(sql: string) {
const tabId = ensureQueryTab();
queryStore.updateSql(tabId, buildAppendedEditorSql(activeTab.value?.sql || "", sql));
function onAiOpenExplainPlan(sql: string, target: AiConversationBinding) {
const tabId = ensureQueryTabForConnection(target);
if (!tabId) return;
queryStore.updateSql(tabId, buildAppendedEditorSql(aiTargetTabSql(tabId), sql));
selectedSql.value = "";
nextTick(() => {
void tryExplain(sql, { tabId });
@@ -4397,8 +4450,8 @@ onUnmounted(() => {
@execute-sql="onAiExecuteSql"
@temp-run-sql="onAiTempRunSql"
@request-auto-execute-sql="onAiRequestAutoExecuteSql"
@insert-redis-command="(command: string) => routeAiRedisCommand(command, false)"
@execute-redis-command="(command: string) => routeAiRedisCommand(command, true)"
@insert-redis-command="(command: string, target: AiConversationBinding) => routeAiRedisCommand(command, false, target)"
@execute-redis-command="(command: string, target: AiConversationBinding) => routeAiRedisCommand(command, true, target)"
@open-explain-plan="onAiOpenExplainPlan"
@toggle-maximize="toggleAiPanelMaximized"
@open-settings="activateSettingsPage"
File diff suppressed because it is too large Load Diff
@@ -75,7 +75,7 @@ describe("Auto picker entry", () => {
expect(resolveSelection).toContain("settings.defaultAutoRouting");
expect(resolveSelection.indexOf('return "auto";')).toBeGreaterThanOrEqual(0);
expect(resolveSelection.indexOf('return "auto";')).toBeLessThan(resolveSelection.indexOf("resolveDefaultAction(mode)"));
expect(resolveSelection).toContain("isVectorDbType(props.connection.db_type)");
expect(resolveSelection).toContain("isVectorDbType(boundConnection.value.db_type)");
});
it("lands new conversations, mode switches and mount init on the settings-driven default", () => {
@@ -97,7 +97,9 @@ describe("Auto picker entry", () => {
const templateGuardIdx = source.lastIndexOf('<template v-if="showActionButtons">', listStart);
expect(templateGuardIdx).toBeGreaterThanOrEqual(0);
const vectorWatchStart = source.indexOf("() => props.connection?.db_type,");
// Keyed by the conversation's bound connection, not the visible tab: the
// hidden action must match whichever database this chat actually targets.
const vectorWatchStart = source.indexOf("() => boundConnection.value?.db_type,");
const vectorWatch = source.slice(vectorWatchStart, source.indexOf("{ immediate: true },", vectorWatchStart));
expect(vectorWatch).toContain('activeAction.value = "generate";');
});
@@ -115,7 +117,9 @@ describe("Auto routing at send time", () => {
// Auto is only routed when it was actually selected; every explicit
// selection is passed through untouched.
expect(sendBody).toContain("requestedAction = requestedSelection;");
expect(sendBody).toContain("requestedAction = await resolveAutoAction(text, requestedMode, runIsVisible());");
// The send pipeline hands the router its frozen run target, so a background
// send cannot route on the visible conversation's SQL or last error.
expect(sendBody).toContain("requestedAction = await resolveAutoAction(text, requestedMode, runIsVisible(), tab);");
// A confirmed-write turn replies with component copy, so the router must not
// run on it: the mode default keeps the pre-Auto confirmation contract.
expect(sendBody).toContain("const confirmationContinuation = allowWriteSqlForNextRun || resumingConfirmedWrite;");
@@ -130,7 +134,7 @@ describe("Auto routing at send time", () => {
expect(sendBody).toContain('userMessage.routedFrom = "auto";');
expect(sendBody).toContain("userMessage.routedAction = requestedAction;");
const resolveBody = bodyOf("async function resolveAutoAction(text: string, mode: AiAssistantMode, showProgress: boolean): Promise<AiAction>");
const resolveBody = bodyOf("async function resolveAutoAction(text: string, mode: AiAssistantMode, showProgress: boolean, target: AiContextTarget = aiContextTarget.value): Promise<AiAction>");
// Zero-latency rule layer first; the classifier (network) only on a miss.
expect(resolveBody.indexOf("routeIntentByRules(input)")).toBeLessThan(resolveBody.indexOf("classifyIntentByLlm(input,"));
expect(resolveBody).toContain("if (showProgress) routingInFlight.value = true;");
@@ -156,7 +160,7 @@ describe("Auto routing at send time", () => {
// normal path, so clearMessages()/selectConversation()/onUnmounted must drop
// the flag synchronously via resetPendingRequestState().
expect(bodyOf("function resetPendingRequestState()")).toContain("routingInFlight.value = false;");
expect(bodyOf("async function resolveAutoAction(text: string, mode: AiAssistantMode, showProgress: boolean): Promise<AiAction>")).toContain("if (showProgress) routingInFlight.value = false;");
expect(bodyOf("async function resolveAutoAction(text: string, mode: AiAssistantMode, showProgress: boolean, target: AiContextTarget = aiContextTarget.value): Promise<AiAction>")).toContain("if (showProgress) routingInFlight.value = false;");
});
it("keeps external explicit triggers on the direct path", () => {
@@ -0,0 +1,253 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Regression coverage for https://github.com/t8y2/dbx/issues/9902:
//
// The AI panel used to take its connection from whatever editor tab was active
// (App.vue passes `:connection="activeConnection"` / `:tab="activeTab"` into a
// single, global panel), and `changeConnection()` even wrote the chosen
// connection back onto that tab — so every conversation shared one connection
// and the composer's database selection leaked between chats.
//
// AiAssistant.vue is a ~6000-line SFC the suite never mounts, so these
// assertions pin the wiring as text, matching the house source-assertion style
// used by AiAssistant.chatTitle.spec.ts. The binding *rules* themselves are
// unit-tested in lib/ai/__tests__/aiConversationBinding.spec.ts.
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
function bodyOf(fnSignature: string): string {
const start = source.indexOf(fnSignature);
expect(start, `expected to find "${fnSignature}" in AiAssistant.vue`).toBeGreaterThanOrEqual(0);
const braceStart = source.indexOf("{", start);
let depth = 0;
for (let i = braceStart; i < source.length; i++) {
if (source[i] === "{") depth++;
else if (source[i] === "}") {
depth--;
if (depth === 0) return source.slice(braceStart, i + 1);
}
}
throw new Error(`unbalanced braces reading body of "${fnSignature}"`);
}
describe("AI conversation owns its connection binding (#9902)", () => {
it("rebinding the conversation never rewrites the editor tab or the global active connection", () => {
const body = bodyOf("async function changeConnection(connectionId: string)");
// These are the calls that made the binding global: they pushed the AI
// panel's choice onto the tab the user was working in.
expect(body).not.toContain("queryStore.updateConnection");
expect(body).not.toContain("queryStore.updateSchema");
expect(body).not.toContain("queryStore.updateDatabase");
expect(body).not.toContain("queryStore.createTab");
expect(body).not.toContain("connectionStore.activeConnectionId");
// It writes the binding onto the conversation instead.
expect(body).toContain("rebindConversation(");
});
it("persists a rebind onto the conversation record", () => {
const body = bodyOf("async function rebindConversation(");
expect(body).toContain("connectionId: connection.id");
expect(body).toContain("saveAiConversation(updated)");
// A chat that has no record yet stages the choice until its first snapshot.
expect(body).toContain("draftBinding.value =");
});
it("resets the composer's database selection when the conversation changes", () => {
// The key must include the conversation id: `selectedDatabases` is one ref
// for the whole panel, so a key without it kept the previous chat's
// database selected — the "bound database is not isolated" report.
const watcherStart = source.indexOf("watch(\n () => `${boundConnectionId.value}");
expect(watcherStart).toBeGreaterThanOrEqual(0);
const watcher = source.slice(watcherStart, source.indexOf(");", watcherStart));
expect(watcher).toContain("conversationId.value");
});
it("sends against the bound connection, not the visible tab", () => {
const body = bodyOf("async function send()");
// The visible tab may contribute editor state, but only through
// aiContextTargetFor(), which gates it on the same namespace; the target
// itself always comes from the binding.
expect(body).toContain("runBinding.connectionId ? connectionStore.getConfig(runBinding.connectionId) : undefined");
expect(body).toContain("aiContextTargetFor(runBinding, props.tab)");
expect(body).not.toContain("props.connection");
});
it("judges production write protection against the bound connection", () => {
// Confirming a write against the bound database must not be vetted against
// whichever tab happens to be visible.
expect(source).toContain("return productionContextForDatabase(connection, target.database);");
expect(source).toContain("const productionContext = computed(() => productionContextOf(activeRunBinding.value));");
});
it("clears a staged draft binding when the shown chat changes", () => {
expect(bodyOf("function selectConversation(conv: AiConversation)")).toContain("draftBinding.value = null;");
expect(bodyOf("function clearMessages()")).toContain("draftBinding.value = null;");
});
it("shows the composer's connection and schema selectors from the binding", () => {
expect(source).toContain(':model-value="boundConnectionId"');
expect(source).toContain(":model-value=\"boundSchema || ''\"");
expect(source).not.toContain(":model-value=\"connection?.id || ''\"");
});
it("labels each history row with the connection it is bound to", () => {
// Which database a conversation talks to has to be readable without opening
// it — the binding is per conversation, and several can be live at once.
expect(source).toContain("{{ conv.connectionName }}");
expect(source).toContain("conversationRowDetail(conv).connectionMissing");
expect(bodyOf("function conversationRowDetail(conv: AiConversation)")).toContain("connectionMissing: !!conv.connectionId && !connectionStore.getConfig(conv.connectionId)");
});
it("retargets the conversation on a cross-connection table drop too", () => {
// Reverses the old "reject a foreign table" contract: with the conversation
// owning its binding, the drop can retarget it instead of being discarded.
const start = source.indexOf("function onTableReferenceDropEvent");
const body = source.slice(start, source.indexOf("\n}", start));
expect(body).toContain("void bindConversation({ connectionId: payload.connectionId, database: payload.database, schema: payload.schema })");
expect(body).toContain("addSelectedMention(");
expect(body).not.toContain("context:");
});
it("retargets the conversation when a table is picked from another connection", () => {
// Sliced between anchors rather than via bodyOf(): the parameter's
// `{ schema?: string; table: string }` annotation is the first "{" after the
// signature, so bodyOf would return the type, not the function body.
const start = source.indexOf("function addTableMention(");
const body = source.slice(start, source.indexOf("function clearContextReferences", start));
expect(body).toContain("void bindConversation(binding)");
expect(body).toContain("addSelectedMention(");
const apply = bodyOf("async function bindConversation(binding: AiConversationBinding)");
// The previous target's mentions and schema options no longer apply...
expect(apply).toContain("clearContextReferences();");
// ...and the new target goes onto the conversation, never the editor.
expect(apply).toContain("rebindConversation(connection, binding.database, binding.schema)");
expect(apply).not.toContain("queryStore.");
expect(apply).not.toContain("activeConnectionId");
});
it("freezes the binding for a background auto-send instead of reading the visible one", () => {
// An auto-send for conversation A runs while B may be on screen, so it must
// carry its own binding: reading the live one would target B's database.
expect(source).toContain("binding: AiConversationBinding;");
const schedule = bodyOf("function scheduleAutoSend(");
expect(schedule).toContain("binding: bindingForSnapshot(conversations.value, convId, conversationBinding.value)");
});
it("derives a run's target from the frozen binding, not the live one", () => {
const body = bodyOf("async function send()");
// A confirmation resume continues an existing run, so the run's frozen
// binding wins over the conversation's live one on that path too.
expect(body).toContain("const runBinding = auto ? auto.binding : (confirmationTarget ?? resumableBinding ?? conversationBinding.value);");
expect(body).toContain("aiContextTargetFor(runBinding, props.tab)");
// A background send has no composer of its own, so its database selection is
// the conversation's own database rather than the visible composer's.
expect(body).toContain("auto || confirmationRetargets ? [runBinding.database] : [...selectedDatabases.value]");
// Only a fresh send reads the live binding; actions emitted by this run
// continue to carry the frozen `runBinding` after any rebind.
expect(body).not.toContain("boundConnection.value");
expect(body).not.toMatch(/emit\("requestAutoExecuteSql", agentPlan\.handoffSql, conversationBinding\.value\)/);
expect(body).toContain('emit("requestAutoExecuteSql", agentPlan.handoffSql, runBinding)');
});
it("keeps the composer's own context unless the confirmation retargets", () => {
// The composer is live while a card is up (`isGenerating` is already false),
// so the user can attach a file and then confirm with "yes". Discarding that
// context is only correct when the target actually moved: send() clears all
// four arrays afterwards, so a drop here loses the attachment silently.
const body = bodyOf("async function send()");
expect(body).toContain("const confirmationRetargets = !!confirmationTarget && !sameConversationBinding(runBinding, conversationBinding.value);");
for (const array of ["selectedTableMentions", "selectedSqlFiles", "csvAttachments", "imageAttachments"]) {
const line = body.split("\n").find((l) => l.includes(`const ${array} = `)) ?? "";
expect(line).toContain("confirmationRetargets");
expect(line).toContain("? [] :");
expect(line).not.toContain("confirmationTarget ?");
}
});
it("compares the whole namespace when deciding whether to retarget", () => {
// Comparing only the connection id would treat db1 -> db2 on one server as
// "no change" and skip the rebind.
const body = bodyOf("async function bindConversation(binding: AiConversationBinding)");
expect(body).toContain("sameConversationBinding(binding, conversationBinding.value)");
expect(body).not.toContain("binding.connectionId === boundConnectionId.value");
});
it("judges production write protection and routing against the run's frozen binding", () => {
// A run's target is frozen at send time, so a rebind mid-run (or while the
// confirmation card is up) must not move the production verdict or the
// routing context onto the new connection. Judging the live binding could
// grant `allowWriteSql` for a production database.
const runBinding = bodyOf("const activeRunBinding = computed");
expect(runBinding).toContain("desktopAiRun<ChatMessage>(conversationId.value)");
expect(runBinding).toContain("activeAiRunBinding(conversationBinding.value, run, messages.value");
expect(source).toContain("const productionContext = computed(() => productionContextOf(activeRunBinding.value));");
const productionOf = bodyOf("function productionContextOf(binding: AiConversationBinding)");
expect(productionOf).toContain("resolveAiDatabaseTarget({ database: binding.database, schema: binding.schema }, connection)");
expect(productionOf).not.toContain("boundDatabase");
const route = bodyOf("async function resolveAutoAction(");
expect(route).toContain("hasCurrentSql: !!target.sql?.trim()");
expect(route).toContain("tabHasLastError(target)");
expect(route).not.toContain("aiContextTarget.value.sql");
// The send pipeline hands it the frozen run target it already computed.
expect(bodyOf("async function send()")).toContain("resolveAutoAction(text, requestedMode, runIsVisible(), tab)");
});
it("confirms a proposed write against the run's binding, not the live one", () => {
// The card belongs to a run. Rebinding while it is up must not append the
// SQL to another connection, nor record the confirmation for one — the
// backend verifies the confirmed namespace against the real execution
// target, so a mismatch would also fail the confirmation.
const body = bodyOf("function sendProposalReply(positive: boolean)");
expect(body).toContain("const runBinding = activeRunBinding.value;");
expect(body).toContain('emit("appendSql", sql, runBinding);');
expect(body).toContain("confirmedConnectionId = runBinding.connectionId;");
expect(body).toContain("resolveAiDatabaseTarget({ database: runBinding.database, schema: runBinding.schema }, runConnection)");
expect(body).not.toContain("conversationBinding.value");
expect(body).not.toContain("boundConnection.value");
expect(body).not.toContain("boundDatabase.value");
expect(body).toContain("confirmationBindingForNextRun = runBinding;");
});
it("keeps the snapshot's connection name and id on the same source", () => {
// Taking the name from the caller (a run, so connection A) while the id comes
// from the conversation record (B after a mid-run rebind) would save a
// record that displays A but targets B.
const body = bodyOf("function buildConversationSnapshot(");
expect(body).toContain("connectionName: binding.connectionId ? (connectionStore.getConfig(binding.connectionId)?.name ?? connectionName) : connectionName,");
expect(body).toContain("connectionId: binding.connectionId,");
// The caller-supplied name is only a fallback now, never stored bare next to
// an id taken from a different source.
expect(body).toContain(": connectionName," + String.fromCharCode(10));
});
it("uses a run's frozen target for the first desktop snapshot", () => {
const persist = bodyOf("async function persistDesktopRunSnapshot(run: DesktopAiRunRuntime<ChatMessage>)");
expect(persist).toContain("{ connectionId: run.connectionId, database: run.database, schema: run.schema }");
const snapshot = bodyOf("function buildConversationSnapshot(");
expect(snapshot).toContain("snapshotBinding(targetConversationId, fallbackBinding)");
expect(snapshot).toContain("schema: binding.schema");
// Leaving the new chat also invokes this ordinary save path before the
// run's scheduled snapshot; it must use the active run's target too.
expect(bodyOf("async function persistConversation()")).toContain("const binding = activeRunBinding.value;");
});
it("persists the assistant turn's target for Web confirmation after remount", () => {
expect(source).toContain("sourceBinding: runBinding");
expect(bodyOf("function buildConversationSnapshot(")).toContain("sourceBinding: m.sourceBinding");
expect(bodyOf("function chatMessagesFromConversation(conv: AiConversation)")).toContain("sourceBinding: m.sourceBinding ??");
const sendBody = bodyOf("async function send()");
expect(sendBody).toContain("typedConfirmationBinding");
expect(sendBody).toContain("productionContextOf(runBinding).active");
});
});
@@ -1,10 +1,10 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import { handleAiTableReferenceDropEvent, type AiTableReferenceDropContext } from "@/lib/ai/aiTableReferenceDrop";
import { handleAiTableReferenceDropEvent } from "@/lib/ai/aiTableReferenceDrop";
import { createTableReferenceDropEvent, createTableReferencePayload, DBX_TABLE_REFERENCE_DROP_EVENT, type QueryEditorTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
function dispatchTableReferenceDrop(payload: QueryEditorTableReferencePayload, context: AiTableReferenceDropContext) {
function dispatchTableReferenceDrop(payload: QueryEditorTableReferencePayload) {
const assistantRoot = document.createElement("div");
const target = document.createElement("span");
assistantRoot.append(target);
@@ -12,7 +12,6 @@ function dispatchTableReferenceDrop(payload: QueryEditorTableReferencePayload, c
const mentions: string[] = [];
const listener = (event: Event) => {
handleAiTableReferenceDropEvent(event, {
context,
assistantRoot,
elementFromPoint: () => target,
onMention: (mention) => mentions.push(mention.raw),
@@ -39,15 +38,20 @@ afterEach(() => {
});
describe("AI assistant table reference drop", () => {
it("accepts a table dropped from the active connection and database", () => {
expect(dispatchTableReferenceDrop(tablePayload(), { connectionId: "conn-1", database: "app-db" })).toEqual(["@public.users"]);
it("accepts a table dropped from the conversation's own connection and database", () => {
expect(dispatchTableReferenceDrop(tablePayload())).toEqual(["@public.users"]);
});
it("rejects a table dropped from another connection", () => {
expect(dispatchTableReferenceDrop(tablePayload({ connectionId: "conn-2" }), { connectionId: "conn-1", database: "app-db" })).toEqual([]);
// #9902 reversed the old "reject a foreign table" contract. It was correct
// while the panel's connection was whatever editor tab was active — there was
// nothing to retarget, so a foreign table had to be dropped. Now the
// conversation owns its binding and the drop retargets it, so the mention is
// accepted and resolved against the database it came from.
it("accepts a table dropped from another connection, so the drop can retarget the conversation", () => {
expect(dispatchTableReferenceDrop(tablePayload({ connectionId: "conn-2" }))).toEqual(["@public.users"]);
});
it("rejects a table dropped from another database", () => {
expect(dispatchTableReferenceDrop(tablePayload({ database: "analytics" }), { connectionId: "conn-1", database: "app-db" })).toEqual([]);
it("accepts a table dropped from another database on the same connection", () => {
expect(dispatchTableReferenceDrop(tablePayload({ database: "analytics" }))).toEqual(["@public.users"]);
});
});
@@ -47,7 +47,7 @@ describe("AI assistant applies per-db_type prompt template defaults", () => {
// Same axis aiDatabaseTypeForConnection established for schema selection:
// gbase → mysql, doris-over-mysql protocol → doris, jdbc → inferred
// dialect. Raw db_type keying would never match for those connections.
expect(source).toContain("const templateDbType = computed(() => (!pluginContext.value && props.connection ? aiDatabaseTypeForConnection(props.connection) : undefined));");
expect(source).toContain("const templateDbType = computed(() => (!pluginContext.value && boundConnection.value ? aiDatabaseTypeForConnection(boundConnection.value) : undefined));");
expect(source).toContain("aiDatabaseTypeForConnection");
});
@@ -187,6 +187,7 @@ import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlF
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { sqlStatementParameterOptionsForCompatibility } from "@/lib/sql/sqlStatementRanges";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { isAiRedisConsoleTarget, type AiConversationBinding } from "@/lib/ai/aiConversationBinding";
type DataGridHandle = DataGridColumnLayoutHandle & {
onToolbarRefresh: () => Promise<void> | void;
@@ -1359,13 +1360,19 @@ function applyTableStructureChanges() {
return tableStructureEditorRef.value?.applyChanges() ?? Promise.resolve(false);
}
async function insertRedisCommand(command: string): Promise<boolean> {
if (props.activeTab.mode !== "redis") return false;
// The Redis console is bound to the visible tab and logical database. Refuse
// any other AI target before passing a command to the key browser (#9902).
function isRedisConsoleReady(target: AiConversationBinding): boolean {
return isAiRedisConsoleTarget(props.activeTab, target) && !!redisKeyBrowserRef.value;
}
async function insertRedisCommand(command: string, target: AiConversationBinding): Promise<boolean> {
if (!isRedisConsoleReady(target)) return false;
return (await redisKeyBrowserRef.value?.insertCommand?.(command)) ?? false;
}
async function executeRedisCommand(command: string): Promise<boolean> {
if (props.activeTab.mode !== "redis") return false;
async function executeRedisCommand(command: string, target: AiConversationBinding): Promise<boolean> {
if (!isRedisConsoleReady(target)) return false;
return (await redisKeyBrowserRef.value?.executeCommand?.(command)) ?? false;
}
@@ -1470,6 +1477,7 @@ defineExpose({
applyTableStructureChanges,
insertRedisCommand,
executeRedisCommand,
isRedisConsoleReady,
previewStatementRange,
focusStatementRange,
focusErrorPosition,
@@ -17,6 +17,7 @@ import { usesProvenReadOnlyStickyTransactionState } from "@/lib/database/databas
import { GROUP_TAB_BAR_PORTAL } from "./groupTabBarPortal";
import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, QueryEditorSurfaceHandle, StatementRange } from "./querySurfaces";
import type { QueryTab } from "@/types/database";
import type { AiConversationBinding } from "@/lib/ai/aiConversationBinding";
defineOptions({ inheritAttrs: false });
@@ -85,8 +86,9 @@ defineExpose({
acceptQueryEditorExecutionViewport: (requestId: number) => activeSurfaceRef.value?.acceptQueryEditorExecutionViewport(requestId) ?? false,
pasteClipboardAsSqlInCondition: () => activeSurfaceRef.value?.pasteClipboardAsSqlInCondition() ?? Promise.resolve(false),
applyTableStructureChanges: () => activeSurfaceRef.value?.applyTableStructureChanges() ?? Promise.resolve(false),
insertRedisCommand: (command: string) => activeSurfaceRef.value?.insertRedisCommand(command) ?? Promise.resolve(false),
executeRedisCommand: (command: string) => activeSurfaceRef.value?.executeRedisCommand(command) ?? Promise.resolve(false),
insertRedisCommand: (command: string, target: AiConversationBinding) => activeSurfaceRef.value?.insertRedisCommand(command, target) ?? Promise.resolve(false),
executeRedisCommand: (command: string, target: AiConversationBinding) => activeSurfaceRef.value?.executeRedisCommand(command, target) ?? Promise.resolve(false),
isRedisConsoleReady: (target: AiConversationBinding) => activeSurfaceRef.value?.isRedisConsoleReady(target) ?? false,
previewStatementRange: (tabId: string, range: StatementRange | null) => (activeTab.value?.id === tabId ? (activeSurfaceRef.value?.previewStatementRange(range) ?? false) : false),
focusStatementRange: (tabId: string, range: StatementRange | null) => (activeTab.value?.id === tabId ? (activeSurfaceRef.value?.focusStatementRange(range) ?? false) : false),
focusErrorPosition: (tabId: string, offset: number) => (activeTab.value?.id === tabId ? (activeSurfaceRef.value?.focusErrorPosition(offset) ?? false) : false),
@@ -3,6 +3,7 @@ import { computed, ref } from "vue";
import ContentArea from "./ContentArea.vue";
import { createContentSurfaceEventForwarders } from "@/lib/tabs/contentSurfaceEvents";
import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, QueryEditorSurfaceHandle, StatementRange } from "./querySurfaces";
import type { AiConversationBinding } from "@/lib/ai/aiConversationBinding";
const props = defineProps<ContentAreaSurfaceProps & { autoFocus?: boolean }>();
const emit = defineEmits<ContentAreaSurfaceEmits>();
@@ -37,8 +38,9 @@ defineExpose<QueryEditorSurfaceHandle>({
acceptQueryEditorExecutionViewport: (requestId: number) => contentAreaRef.value?.acceptQueryEditorExecutionViewport(requestId) ?? false,
pasteClipboardAsSqlInCondition: () => contentAreaRef.value?.pasteClipboardAsSqlInCondition() ?? Promise.resolve(false),
applyTableStructureChanges: () => contentAreaRef.value?.applyTableStructureChanges() ?? Promise.resolve(false),
insertRedisCommand: (command: string) => contentAreaRef.value?.insertRedisCommand(command) ?? Promise.resolve(false),
executeRedisCommand: (command: string) => contentAreaRef.value?.executeRedisCommand(command) ?? Promise.resolve(false),
insertRedisCommand: (command: string, target: AiConversationBinding) => contentAreaRef.value?.insertRedisCommand(command, target) ?? Promise.resolve(false),
executeRedisCommand: (command: string, target: AiConversationBinding) => contentAreaRef.value?.executeRedisCommand(command, target) ?? Promise.resolve(false),
isRedisConsoleReady: (target: AiConversationBinding) => contentAreaRef.value?.isRedisConsoleReady(target) ?? false,
previewStatementRange: (range: StatementRange | null) => contentAreaRef.value?.previewStatementRange(range) ?? false,
focusStatementRange: (range: StatementRange | null) => contentAreaRef.value?.focusStatementRange(range) ?? false,
focusErrorPosition: (offset: number) => contentAreaRef.value?.focusErrorPosition(offset) ?? false,
@@ -16,6 +16,7 @@ import QueryResultSurface from "./QueryResultSurface.vue";
import { createContentSurfaceEventForwarders } from "@/lib/tabs/contentSurfaceEvents";
import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, StatementRange } from "./querySurfaces";
import type { QueryTab } from "@/types/database";
import type { AiConversationBinding } from "@/lib/ai/aiConversationBinding";
defineOptions({ inheritAttrs: false });
@@ -107,8 +108,11 @@ defineExpose({
const group = groupForElement(commandTargetElement(null));
return group?.applyTableStructureChanges() ?? activeEditorGroup()?.applyTableStructureChanges() ?? Promise.resolve(false);
},
insertRedisCommand: (command: string) => (groupForElement(commandTargetElement(null)) ?? activeEditorGroup())?.insertRedisCommand(command) ?? Promise.resolve(false),
executeRedisCommand: (command: string) => (groupForElement(commandTargetElement(null)) ?? activeEditorGroup())?.executeRedisCommand(command) ?? Promise.resolve(false),
// Redis's logical DB is part of the target, so a visible tab on another DB
// must be refused even when it shares the same connection.
insertRedisCommand: (command: string, target: AiConversationBinding) => (groupForElement(commandTargetElement(null)) ?? activeEditorGroup())?.insertRedisCommand(command, target) ?? Promise.resolve(false),
executeRedisCommand: (command: string, target: AiConversationBinding) => (groupForElement(commandTargetElement(null)) ?? activeEditorGroup())?.executeRedisCommand(command, target) ?? Promise.resolve(false),
isRedisConsoleReady: (target: AiConversationBinding) => (groupForElement(commandTargetElement(null)) ?? activeEditorGroup())?.isRedisConsoleReady(target) ?? false,
});
const { t } = useI18n();
@@ -3,6 +3,7 @@ import type { DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import type { SqlExecutionOverride, SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
import type { AiConversationBinding } from "@/lib/ai/aiConversationBinding";
export interface StatementRange {
from: number;
@@ -25,8 +26,17 @@ export interface QueryEditorSurfaceHandle {
acceptQueryEditorExecutionViewport(requestId: number): boolean;
pasteClipboardAsSqlInCondition(): Promise<boolean>;
applyTableStructureChanges(): Promise<boolean>;
insertRedisCommand(command: string): Promise<boolean>;
executeRedisCommand(command: string): Promise<boolean>;
/** A Redis logical database is part of the execution target. */
insertRedisCommand(command: string, target: AiConversationBinding): Promise<boolean>;
executeRedisCommand(command: string, target: AiConversationBinding): Promise<boolean>;
/**
* Whether this surface's Redis console is mounted, on screen, and pointed at
* `target`. Side-effect free — the AI panel polls it before routing a
* command, because the console is a lazily-loaded component rendered only for
* the active tab, and retrying the *command* to detect readiness could run it
* twice.
*/
isRedisConsoleReady(target: AiConversationBinding): boolean;
previewStatementRange(range: StatementRange | null): boolean;
focusStatementRange(range: StatementRange | null): boolean;
focusErrorPosition(offset: number): boolean;
+1
View File
@@ -2985,6 +2985,7 @@ export default withEnglishFallback({
truncated: "Kontekst kəsildi",
contextSummary: "{database} · {tables} cədvəl",
autoSqlBlocked: "AI-nin yaratdığı SQL avtomatik icra üçün həddindən artıq riskli göründü. İşlətməzdən əvvəl onu əl ilə nəzərdən keçirin.",
redisConsoleUnreachable: "AI əmri bu söhbətə bağlı Redis konsoluna çatmadı. Həmin bağlantının Redis konsolunu açıb yenidən cəhd edin.",
writeSqlConfirmationRequired: "Bu SQL məlumatları və ya sxemi dəyişir. İcradan əvvəl diqqətlə nəzərdən keçirin.",
writeSqlConfirmationQuestion: "Bu SQL bir dəfə icra edilsin?",
writeSqlConfirmYes: "İcranı təsdiqlə",
+1
View File
@@ -3219,6 +3219,7 @@ 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.",
redisConsoleUnreachable: "The AI command could not reach this conversation's Redis console. Open the bound connection's Redis console and try again.",
writeSqlConfirmationRequired: "This SQL changes data or schema. Review it carefully before execution.",
writeSqlConfirmationQuestion: "Execute this SQL once?",
writeSqlConfirmYes: "Confirm execution",
+1
View File
@@ -3218,6 +3218,7 @@ export default withEnglishFallback({
truncated: "Contexto truncado",
contextSummary: "{database} · {tables} tablas",
autoSqlBlocked: "El SQL generado por la IA parecía demasiado riesgoso para ejecutarse automáticamente. Revísalo manualmente antes de ejecutar.",
redisConsoleUnreachable: "El comando de la IA no pudo llegar a la consola de Redis vinculada a esta conversación. Abre la consola de Redis de esa conexión e inténtalo de nuevo.",
writeSqlConfirmationRequired: "Este SQL modifica datos o el esquema. Revísalo cuidadosamente antes de ejecutarlo.",
writeSqlConfirmationQuestion: "¿Ejecutar este SQL una vez?",
writeSqlConfirmYes: "Confirmar ejecución",
+1
View File
@@ -3111,6 +3111,7 @@ export default withEnglishFallback({
truncated: "Contesto troncato",
contextSummary: "{database} · {tables} tabelle",
autoSqlBlocked: "L'SQL generato dall'AI è sembrato troppo rischioso per l'esecuzione automatica. Rivedilo manualmente prima di eseguirlo.",
redisConsoleUnreachable: "Il comando dell'AI non ha raggiunto la console Redis associata a questa conversazione. Apri la console Redis di quella connessione e riprova.",
writeSqlConfirmationRequired: "Questo SQL modifica dati o schema. Rivedilo attentamente prima dell'esecuzione.",
writeSqlConfirmationQuestion: "Eseguire questo SQL una volta?",
writeSqlConfirmYes: "Conferma esecuzione",
+1
View File
@@ -3252,6 +3252,7 @@ export default withEnglishFallback({
truncated: "コンテキストが切り詰められました",
contextSummary: "{database} · {tables}テーブル",
autoSqlBlocked: "AIが生成したSQLは自動実行するにはリスクが高すぎると判断されました。実行前に手動で確認してください。",
redisConsoleUnreachable: "AI コマンドをこの会話に紐づく Redis コンソールに送信できませんでした。その接続の Redis コンソールを開いて再試行してください。",
writeSqlConfirmationRequired: "この SQL はデータまたはスキーマを変更します。実行前に内容をよく確認してください。",
writeSqlConfirmationQuestion: "この SQL を一度実行しますか?",
writeSqlConfirmYes: "実行を確認",
+1
View File
@@ -3016,6 +3016,7 @@ export default withEnglishFallback({
truncated: "컨텍스트가 잘렸습니다",
contextSummary: "{database} · 테이블 {tables}개",
autoSqlBlocked: "AI가 생성한 SQL이 자동 실행하기에는 너무 위험해 보입니다. 실행 전에 수동으로 검토하세요.",
redisConsoleUnreachable: "AI 명령을 이 대화에 바인딩된 Redis 콘솔로 전달하지 못했습니다. 해당 연결의 Redis 콘솔을 열고 다시 시도하세요.",
writeSqlConfirmationRequired: "이 SQL은 데이터 또는 스키마를 변경합니다. 실행 전에 신중하게 검토하세요.",
writeSqlConfirmationQuestion: "이 SQL을 한 번 실행할까요?",
writeSqlConfirmYes: "실행 확인",
+1
View File
@@ -3219,6 +3219,7 @@ export default withEnglishFallback({
truncated: "Contexto truncado",
contextSummary: "{database} · {tables} tabelas",
autoSqlBlocked: "O SQL gerado pela AI pareceu arriscado demais para execução automática. Revise-o manualmente antes de executar.",
redisConsoleUnreachable: "O comando da AI não chegou ao console Redis vinculado a esta conversa. Abra o console Redis dessa conexão e tente novamente.",
writeSqlConfirmationRequired: "Este SQL altera dados ou o esquema. Revise-o cuidadosamente antes de executar.",
writeSqlConfirmationQuestion: "Executar este SQL uma vez?",
writeSqlConfirmYes: "Confirmar execução",
+1
View File
@@ -2961,6 +2961,7 @@ export default withEnglishFallback({
truncated: "Bağlam kısaltıldı",
contextSummary: "{database} · {tables} tablo",
autoSqlBlocked: "Yapay zekânın ürettiği SQL, otomatik çalıştırmak için fazla riskli göründü. Çalıştırmadan önce elle inceleyin.",
redisConsoleUnreachable: "AI komutu bu konuşmaya bağlı Redis konsoluna ulaşamadı. O bağlantının Redis konsolunu açıp yeniden deneyin.",
writeSqlConfirmationRequired: "Bu SQL veriyi veya şemayı değiştiriyor. Çalıştırmadan önce dikkatle inceleyin.",
writeSqlConfirmationQuestion: "Bu SQL bir kez çalıştırılsın mı?",
writeSqlConfirmYes: "Çalıştırmayı onayla",
+1
View File
@@ -3131,6 +3131,7 @@ export default withEnglishFallback({
truncated: "上下文已截断",
contextSummary: "{database} · {tables} 张表",
autoSqlBlocked: "AI 生成的 SQL 风险较高,已阻止自动执行,请手动检查后再运行。",
redisConsoleUnreachable: "AI 命令未能送达本会话绑定的 Redis 控制台。请打开该连接的 Redis 控制台后重试。",
writeSqlConfirmationRequired: "此 SQL 会修改数据或结构。执行前请仔细核对。",
writeSqlConfirmationQuestion: "确认执行此 SQL 一次吗?",
writeSqlConfirmYes: "确认执行",
+1
View File
@@ -3110,6 +3110,7 @@ export default withEnglishFallback({
truncated: "上下文已截斷",
contextSummary: "{database} · {tables} 張資料表",
autoSqlBlocked: "AI 產生的 SQL 風險較高,已阻止自動執行,請手動檢查後再執行。",
redisConsoleUnreachable: "AI 指令未能送達本工作階段綁定的 Redis 控制台。請開啟該連線的 Redis 控制台後重試。",
writeSqlConfirmationRequired: "此 SQL 會修改資料或結構。執行前請仔細核對。",
writeSqlConfirmationQuestion: "確認執行此 SQL 一次嗎?",
writeSqlConfirmYes: "確認執行",
@@ -20,6 +20,46 @@ describe("add to AI database context", () => {
expect(addToAiSource).toContain("Array.isArray(nodesInput) ? nodesInput : [nodesInput]");
expect(addToAiSource).toContain('entry.type === "table"');
expect(addToAiSource).toContain("for (const mention of tableMentions) handle.addTableMention(mention)");
// Every selected table still becomes a mention, and it now carries the
// binding the conversation must adopt (#9902).
expect(addToAiSource).toContain("for (const mention of tableMentions) handle.addTableMention(mention, binding)");
});
it("retargets the conversation instead of moving the workspace (#9902)", () => {
const addToAiStart = appSource.indexOf("async function addToAi");
const addToAiEnd = appSource.indexOf("function openAiPanel", addToAiStart);
const addToAiSource = appSource.slice(addToAiStart, addToAiEnd);
expect(addToAiSource).toContain("const binding: AiConversationBinding = { connectionId: node.connectionId");
// Asking about a table must not reassign the global active connection or
// switch/create an editor tab — that is what made the AI panel's target
// global.
expect(addToAiSource).not.toContain("connectionStore.activeConnectionId =");
expect(addToAiSource).not.toContain("queryStore.switchTab(");
expect(addToAiSource).not.toContain("queryStore.createTab(");
});
it("retargets even when the entry adds no table mention", () => {
// "Ask AI" on a connection or database node produces no mentions at all, so
// applying the binding only inside the mention loop would silently skip it.
const addToAiStart = appSource.indexOf("async function addToAi");
const addToAiEnd = appSource.indexOf("function openAiPanel", addToAiStart);
const addToAiSource = appSource.slice(addToAiStart, addToAiEnd);
expect(addToAiSource).toContain("void handle.bindConversation(binding);");
// The call must not live inside the mention loop.
const loopIdx = addToAiSource.indexOf("for (const mention of tableMentions)");
const bindIdx = addToAiSource.indexOf("void handle.bindConversation(binding);");
expect(bindIdx).toBeLessThan(loopIdx);
});
it("resolves the AI execution tab by the exact namespace", () => {
// `(!schema || tab.schema === schema)` used to accept a tab on ANY schema
// when the target had none, and execution then inherited that tab's schema.
const start = appSource.indexOf("function ensureQueryTabForConnection");
const body = appSource.slice(start, appSource.indexOf("queryStore.createTab", start));
expect(body).toContain("(tab.schema || undefined) === schema");
expect(body).not.toContain("!schema ||");
});
});
@@ -3,8 +3,6 @@ import { aiTableMentionFromTableReference } from "@/lib/ai/aiTableReferenceDrop"
import { createTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
describe("ai table reference drop", () => {
const context = { connectionId: "conn-1", database: "app-db" };
it("maps a table payload to a table mention", () => {
const payload = createTableReferencePayload({
connectionId: "conn-1",
@@ -14,7 +12,7 @@ describe("ai table reference drop", () => {
databaseType: "postgres",
});
expect(aiTableMentionFromTableReference(payload, context)).toEqual({
expect(aiTableMentionFromTableReference(payload)).toEqual({
raw: "@public.users",
schema: "public",
table: "users",
@@ -29,7 +27,7 @@ describe("ai table reference drop", () => {
databaseType: "mysql",
});
expect(aiTableMentionFromTableReference(payload, context)).toEqual({
expect(aiTableMentionFromTableReference(payload)).toEqual({
raw: '@"order items"',
schema: undefined,
table: "order items",
@@ -44,7 +42,7 @@ describe("ai table reference drop", () => {
databaseType: "mysql",
});
expect(aiTableMentionFromTableReference(payload, context)).toBeNull();
expect(aiTableMentionFromTableReference(payload)).toBeNull();
});
it("ignores column references", () => {
@@ -57,11 +55,11 @@ describe("ai table reference drop", () => {
databaseType: "postgres",
});
expect(aiTableMentionFromTableReference(payload, context)).toBeNull();
expect(aiTableMentionFromTableReference(payload)).toBeNull();
});
it("ignores empty payloads", () => {
expect(aiTableMentionFromTableReference(null, context)).toBeNull();
expect(aiTableMentionFromTableReference(undefined, context)).toBeNull();
expect(aiTableMentionFromTableReference(null)).toBeNull();
expect(aiTableMentionFromTableReference(undefined)).toBeNull();
});
});
@@ -0,0 +1,196 @@
import { describe, expect, it } from "vitest";
import type { AiConversation } from "@/lib/backend/tauri";
import { activeAiRunBinding, aiContextTargetFor, bindingForSnapshot, isAiRedisConsoleTarget, isBindingUnresolved, resolveConversationBinding, sameConversationBinding, type AiConversationBinding } from "@/lib/ai/aiConversationBinding";
function conversation(overrides: Partial<AiConversation> & { id: string }): AiConversation {
return {
title: overrides.id,
connectionName: "",
connectionId: "",
database: "",
messages: [],
createdAt: "2026-09-23T00:00:00.000Z",
updatedAt: "2026-09-23T00:00:00.000Z",
...overrides,
};
}
const AMBIENT = { connectionId: "conn-visible-tab", database: "ambient_db", schema: "ambient_schema" };
describe("resolveConversationBinding", () => {
it("uses the conversation's own binding, not the visible tab", () => {
const binding = resolveConversationBinding(conversation({ id: "c1", connectionId: "conn-bound", database: "bound_db", schema: "bound_schema" }), null, AMBIENT);
expect(binding).toEqual({ connectionId: "conn-bound", database: "bound_db", schema: "bound_schema" });
});
it("keeps an unbound conversation unbound instead of falling back to the visible tab", () => {
// An empty id means "deliberately unbound": the stored name matched zero or
// several saved connections, or the bound connection was deleted. Inheriting
// the active tab here is the exact leak #9902 reports.
const binding = resolveConversationBinding(conversation({ id: "c2", connectionName: "gone" }), null, AMBIENT);
expect(binding.connectionId).toBe("");
expect(binding.database).toBe("");
});
it("lets a draft binding chosen before the first message win over the tab", () => {
const draft: AiConversationBinding = { connectionId: "conn-draft", database: "draft_db" };
const binding = resolveConversationBinding(undefined, draft, AMBIENT);
expect(binding).toEqual(draft);
});
it("falls back to the ambient tab only for a chat with no persisted record", () => {
expect(resolveConversationBinding(undefined, null, AMBIENT)).toEqual({
connectionId: "conn-visible-tab",
database: "ambient_db",
schema: "ambient_schema",
});
});
it("tolerates an empty ambient tab", () => {
expect(resolveConversationBinding(undefined, null, {})).toEqual({ connectionId: "", database: "", schema: undefined });
});
});
describe("isBindingUnresolved", () => {
it("flags only a persisted conversation with no connection", () => {
expect(isBindingUnresolved(conversation({ id: "c1" }), { connectionId: "", database: "" })).toBe(true);
// A brand-new chat resolves through the ambient tab, so it is never flagged.
expect(isBindingUnresolved(undefined, { connectionId: "", database: "" })).toBe(false);
expect(isBindingUnresolved(conversation({ id: "c1", connectionId: "conn-a" }), { connectionId: "conn-a", database: "" })).toBe(false);
});
});
describe("bindingForSnapshot", () => {
it("keeps an existing conversation's binding even when the fallback points elsewhere", () => {
// Regression guard for #9902: persistence used to re-derive the connection
// from whatever tab was active, which is how one connection leaked across
// every conversation.
const conversations = [conversation({ id: "c1", connectionId: "conn-a", database: "db_a" })];
const binding = bindingForSnapshot(conversations, "c1", { connectionId: "conn-b", database: "db_b" });
expect(binding).toEqual({ connectionId: "conn-a", database: "db_a", schema: undefined });
});
it("uses the fallback for a conversation that has not been saved yet", () => {
const fallback: AiConversationBinding = { connectionId: "conn-new", database: "db_new", schema: "public" };
expect(bindingForSnapshot([], "brand-new", fallback)).toEqual(fallback);
});
it("does not resurrect a binding for another conversation's id", () => {
const conversations = [conversation({ id: "other", connectionId: "conn-a", database: "db_a" })];
expect(bindingForSnapshot(conversations, "c1", { connectionId: "conn-b", database: "db_b" }).connectionId).toBe("conn-b");
});
});
describe("aiContextTargetFor", () => {
const binding: AiConversationBinding = { connectionId: "conn-a", database: "db_a" };
it("carries the visible editor's SQL, result and focused table on the same connection", () => {
const result = { columns: ["id"], rows: [[1]] } as never;
const tableMeta = { tableName: "users", schema: "public", columns: [] } as never;
const target = aiContextTargetFor(binding, { connectionId: "conn-a", database: "db_a", sql: "select 1", result, tableMeta });
expect(target.connectionId).toBe("conn-a");
expect(target.database).toBe("db_a");
expect(target.sql).toBe("select 1");
expect(target.result).toBe(result);
expect(target.tableMeta).toBe(tableMeta);
});
it("drops the editor state of another database on the same connection", () => {
// Same server, different database: still a different target. Letting this
// through would send another database's SQL and result rows to the provider
// as "context" for a conversation bound elsewhere.
const target = aiContextTargetFor(binding, {
connectionId: "conn-a",
database: "db_b",
sql: "select * from other_db_only_table",
result: { columns: [], rows: [] } as never,
tableMeta: { tableName: "other_db_only_table", columns: [] } as never,
});
expect(target.connectionId).toBe("conn-a");
expect(target.database).toBe("db_a");
expect(target.sql).toBeUndefined();
expect(target.result).toBeUndefined();
expect(target.tableMeta).toBeUndefined();
});
it("drops another connection's editor state so its SQL cannot leak into this chat", () => {
const target = aiContextTargetFor(binding, {
connectionId: "conn-b",
database: "db_a",
sql: "delete from prod_orders",
result: { columns: [], rows: [] } as never,
tableMeta: { tableName: "prod_orders", columns: [] } as never,
});
expect(target.connectionId).toBe("conn-a");
expect(target.sql).toBeUndefined();
expect(target.result).toBeUndefined();
expect(target.tableMeta).toBeUndefined();
});
it("omits editor state when no tab is visible", () => {
expect(aiContextTargetFor(binding, undefined)).toEqual({ connectionId: "conn-a", database: "db_a", schema: undefined });
});
});
describe("sameConversationBinding", () => {
it("treats a different database or schema on the same server as a different target", () => {
const base: AiConversationBinding = { connectionId: "conn-a", database: "prod", schema: "public" };
expect(sameConversationBinding(base, { ...base })).toBe(true);
expect(sameConversationBinding(base, { ...base, database: "test" })).toBe(false);
expect(sameConversationBinding(base, { ...base, schema: "private" })).toBe(false);
expect(sameConversationBinding(base, { ...base, connectionId: "conn-b" })).toBe(false);
});
it("normalizes an absent database or schema to empty", () => {
expect(sameConversationBinding({ connectionId: "c", database: "" }, { connectionId: "c" })).toBe(true);
expect(sameConversationBinding({ connectionId: "c", database: "", schema: "" }, { connectionId: "c", database: "" })).toBe(true);
});
});
describe("activeAiRunBinding", () => {
const live: AiConversationBinding = { connectionId: "conn-b", database: "db_b", schema: "private" };
const frozen: AiConversationBinding = { connectionId: "conn-a", database: "db_a", schema: "public" };
const run = { ...frozen, status: "awaiting_write_confirmation" as const };
it("continues an awaiting desktop confirmation on its frozen target", () => {
expect(activeAiRunBinding(live, run, [], false)).toEqual(frozen);
for (const status of ["preparing", "queued", "running"] as const) {
expect(activeAiRunBinding(live, { ...run, status }, [], false)).toEqual(frozen);
}
});
it("uses the current binding for an editable recovered draft or terminal run", () => {
expect(activeAiRunBinding(live, { ...run, status: "pending_recoverable" }, [], false)).toEqual(live);
expect(activeAiRunBinding(live, { ...run, status: "completed" }, [], false)).toEqual(live);
});
it("keeps a Web proposal on the assistant message's source after rebinding", () => {
const messages = [{ role: "user" }, { role: "assistant", sourceBinding: frozen }];
expect(activeAiRunBinding(live, undefined, messages, true)).toEqual(frozen);
expect(activeAiRunBinding(live, undefined, messages, false)).toEqual(live);
});
});
describe("isAiRedisConsoleTarget", () => {
const target: AiConversationBinding = { connectionId: "redis-a", database: "1" };
it("requires the exact Redis logical database as well as the connection", () => {
expect(isAiRedisConsoleTarget({ mode: "redis", connectionId: "redis-a", database: "1" }, target)).toBe(true);
expect(isAiRedisConsoleTarget({ mode: "redis", connectionId: "redis-a", database: "0" }, target)).toBe(false);
expect(isAiRedisConsoleTarget({ mode: "redis", connectionId: "redis-b", database: "1" }, target)).toBe(false);
expect(isAiRedisConsoleTarget({ mode: "query", connectionId: "redis-a", database: "1" }, target)).toBe(false);
});
});
@@ -9,33 +9,87 @@ const redisBrowserSource = readFileSync(new URL("../../../components/redis/Redis
describe("AI Redis console routing", () => {
it("routes Redis insert and execute actions to the active Redis console", () => {
expect(aiAssistantSource).toContain('emit("insertRedisCommand", code)');
expect(aiAssistantSource).toContain('emit("executeRedisCommand", code)');
// Every action carries the conversation's bound connection (#9902); the
// console refuses a target that is not the visible tab.
expect(aiAssistantSource).toContain('emit("insertRedisCommand", code, conversationBinding.value)');
expect(aiAssistantSource).toContain('emit("executeRedisCommand", code, conversationBinding.value)');
expect(aiAssistantSource).toContain("seg.isSql || isRedisConnection");
expect(appSource).toContain("if (routeAiRedisCommand(sql, false)) return;");
expect(appSource).toContain("if (routeAiRedisCommand(sql, true)) return;");
expect(contentAreaSource).toContain('props.activeTab.mode !== "redis"');
expect(appSource).toContain("if (routeAiRedisCommand(sql, false, target)) return;");
expect(appSource).toContain("if (routeAiRedisCommand(sql, true, target)) return;");
expect(contentAreaSource).toContain("if (!isRedisConsoleReady(target)) return false;");
expect(contentAreaSource).toContain("redisKeyBrowserRef.value?.executeCommand?.(command)");
});
it("keeps the existing SQL editor and execution behavior for non-Redis connections", () => {
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(aiAssistantSource).toContain('emit("appendSql", code, conversationBinding.value)');
expect(aiAssistantSource).toContain('emit("executeSql", code, conversationBinding.value)');
expect(aiAssistantSource).toContain('emit("tempRunSql", code, conversationBinding.value)');
// The target tab is resolved from the bound connection, never from the tab
// that happens to be active (#9902).
expect(appSource).toContain("const tabId = ensureQueryTabForConnection(target);");
expect(appSource).not.toContain("function ensureQueryTab()");
expect(appSource).toContain("buildDeduplicatedAppendedEditorSql(currentSql, sql)");
expect(appSource).toContain('buildAppendedEditorSql(activeTab.value?.sql || "", sql)');
expect(appSource).toContain("const decision = classifyAiSqlExecution(sql, activeConnection.value);");
expect(appSource).toContain("buildAppendedEditorSql(aiTargetTabSql(tabId), sql)");
expect(appSource).toContain("const decision = classifyAiSqlExecution(sql, connection);");
});
it("deduplicates immediate-run editor writes without skipping execution", () => {
const handler = appSource.match(/function onAiExecuteSql\(sql: string\) \{[\s\S]*?\n\}/)?.[0] ?? "";
const handler = appSource.match(/function onAiExecuteSql\(sql: string, target: AiConversationBinding\) \{[\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, tabId);");
expect(handler).not.toContain("buildAppendedEditorSql(");
});
it("refuses a Redis command rather than moving the workspace to the bound console", () => {
// "An AI action never moves the workspace" is the point of #9902. The bound
// console is driven only when it is already the visible tab; otherwise the
// user is told to open it. No tab is switched and none is created.
const start = appSource.indexOf("function routeAiRedisCommand");
const route = appSource.slice(start, appSource.indexOf("function aiTargetTabSql", start));
expect(route).not.toContain("switchTab");
expect(route).not.toContain("createTab");
expect(route).not.toContain("ensureRedisConsoleTab");
expect(appSource).not.toContain("ensureRedisConsoleTab");
expect(route).toContain("void deliverRedisAiCommand(command, execute, target);");
// The refusal is reported, not silent.
expect(appSource).toContain('toast(t("ai.redisConsoleUnreachable"), 5000);');
});
it("still drives the bound console when it is the visible tab", () => {
// The refusal above must not disable the working case: readiness means the
// on-screen console belongs to the bound connection.
expect(contentAreaSource).toContain("isAiRedisConsoleTarget(props.activeTab, target)");
const start = appSource.indexOf("async function deliverRedisAiCommand");
const deliver = appSource.slice(start, appSource.indexOf("function aiTargetTabSql", start));
expect(deliver).toContain("contentAreaRef.value?.executeRedisCommand(command, target)");
expect(deliver).toContain("contentAreaRef.value?.insertRedisCommand(command, target)");
});
it("waits for the console to mount without re-issuing the command", () => {
const start = appSource.indexOf("async function deliverRedisAiCommand");
expect(start).toBeGreaterThanOrEqual(0);
const deliver = appSource.slice(start, appSource.indexOf("\n}", start));
// Readiness is polled through a side-effect-free probe...
const probeIdx = deliver.indexOf("isRedisConsoleReady(target)");
expect(probeIdx).toBeGreaterThanOrEqual(0);
expect(deliver).toContain("REDIS_CONSOLE_READY_TIMEOUT_MS");
// ...and the single route attempt happens only after the wait, so a command
// that ran but reported false (e.g. awaiting confirmation) cannot run twice.
const routeIdx = deliver.indexOf("executeRedisCommand(command, target)");
expect(routeIdx).toBeGreaterThan(probeIdx);
// The probe itself must not execute anything.
const probeStart = contentAreaSource.indexOf("function isRedisConsoleReady");
expect(probeStart).toBeGreaterThanOrEqual(0);
const probe = contentAreaSource.slice(probeStart, contentAreaSource.indexOf("\n}", probeStart));
expect(probe).toContain("isAiRedisConsoleTarget(props.activeTab, target) && !!redisKeyBrowserRef.value");
expect(probe).not.toContain("executeCommand");
expect(probe).not.toContain("insertCommand");
});
it("uses the console safety path and rejects unavailable command input", () => {
expect(classifyRedisCommandSafety("CONFIG SET requirepass secret")).toBe("blocked");
expect(classifyRedisCommandSafety("FLUSHDB")).toBe("confirm");
@@ -63,4 +117,16 @@ describe("AI Redis console routing", () => {
expect(anyBlocked).toBe(true);
}
});
it("tells the user when the bound Redis console cannot be reached", () => {
// A console.warn is invisible in a desktop app, and the command the user
// asked for is not going to run — silence is the worst outcome.
// Sliced between two anchors: writing an escaped newline in a source anchor
// is fragile, and this pair is unambiguous.
const start = appSource.indexOf("async function deliverRedisAiCommand");
const deliver = appSource.slice(start, appSource.indexOf("function aiTargetTabSql", start));
expect(deliver).toContain('toast(t("ai.redisConsoleUnreachable"), 5000);');
// Both failure paths (mount timeout, command rejected) report.
expect(deliver.split('toast(t("ai.redisConsoleUnreachable")').length - 1).toBe(2);
});
});
+36 -9
View File
@@ -746,8 +746,35 @@ function formatAttachedTextData(context: AiContext, isZh: boolean): string {
].join("\n\n");
}
/** The namespace-defining subset of a query tab: what identifies the database /
* schema an AI request targets. A `QueryTab` satisfies this structurally, so
* editor-tab callers are unaffected; the AI panel passes a conversation-bound
* target instead (#9902). */
export interface AiNamespaceSource {
database?: string;
schema?: string;
}
/** Everything `buildAiContext` needs to describe what the AI is looking at.
*
* A `QueryTab` satisfies this structurally, so passing one still works. The AI
* panel passes a *conversation-bound* target instead: the namespace must follow
* the conversation, not whichever editor tab happens to be active, otherwise
* two conversations share one connection (#9902).
*
* `sql` / `result` / `tableMeta` describe the editor the user is looking at, so
* the panel only attaches them when that tab is on the bound connection —
* another connection's SQL and results are not context for this conversation.
*/
export interface AiContextTarget extends AiNamespaceSource {
connectionId: string;
sql?: string;
result?: QueryResult;
tableMeta?: QueryTab["tableMeta"];
}
export async function buildAiContext(
tab: QueryTab,
tab: AiContextTarget,
connection: ConnectionConfig,
options: { maxTables?: number; maxColumnsPerTable?: number; maxIndexesPerTable?: number; maxFksPerTable?: number; mentionedTables?: AiTableMention[]; sqlFiles?: AiSqlFileContext[]; csvFiles?: AiCsvFileContext[] } = {},
): Promise<AiContext> {
@@ -873,7 +900,7 @@ export async function buildAiContext(
databaseType,
database,
schema,
currentSql: currentCollectionName ?? tab.sql,
currentSql: currentCollectionName ?? tab.sql ?? "",
lastError: extractLastError(tab.result),
lastResultPreview: formatResultPreview(tab.result),
tables,
@@ -884,7 +911,7 @@ export async function buildAiContext(
};
}
async function loadMentionedTableContext(tab: QueryTab, connection: ConnectionConfig, mention: AiTableMention, maxColumnsPerTable: number, maxIndexesPerTable: number, maxFksPerTable: number): Promise<AiSchemaTable | undefined> {
async function loadMentionedTableContext(tab: AiContextTarget, connection: ConnectionConfig, mention: AiTableMention, maxColumnsPerTable: number, maxIndexesPerTable: number, maxFksPerTable: number): Promise<AiSchemaTable | undefined> {
const databaseType = aiDatabaseTypeForConnection(connection);
const database = aiDatabaseNamespace(tab, connection);
const schema = await resolveMentionedTableSchema(tab, connection, mention);
@@ -910,7 +937,7 @@ async function loadTableComment(connectionId: string, database: string, schema:
return tables.find((table) => table.name.toLowerCase() === tableName.toLowerCase())?.comment?.trim() || undefined;
}
async function resolveMentionedTableSchema(tab: QueryTab, connection: ConnectionConfig, mention: AiTableMention): Promise<string> {
async function resolveMentionedTableSchema(tab: AiContextTarget, connection: ConnectionConfig, mention: AiTableMention): Promise<string> {
if (mention.schema) return mention.schema;
if (tab.tableMeta?.tableName.toLowerCase() === mention.table.toLowerCase() && tab.tableMeta.schema) {
return tab.tableMeta.schema;
@@ -926,7 +953,7 @@ async function resolveMentionedTableSchema(tab: QueryTab, connection: Connection
return aiDatabaseNamespace(tab, connection);
}
async function loadCandidateSchemas(tab: QueryTab, connection: ConnectionConfig): Promise<string[]> {
async function loadCandidateSchemas(tab: AiContextTarget, connection: ConnectionConfig): Promise<string[]> {
const { database, schema } = resolveAiDatabaseTarget(tab, connection);
if (schema) return [schema];
if (isSchemaAware(aiDatabaseTypeForConnection(connection))) {
@@ -951,11 +978,11 @@ export function aiSchemaSelectionSupported(connection: ConnectionConfig): boolea
return isSchemaAware(aiDatabaseTypeForConnection(connection));
}
function aiDatabaseNamespace(tab: QueryTab, connection: ConnectionConfig): string {
function aiDatabaseNamespace(tab: AiNamespaceSource, connection: ConnectionConfig): string {
return resolveAiDatabaseTarget(tab, connection).database;
}
export function resolveAiNamespaceSelection(tab: QueryTab, connection: ConnectionConfig): AiNamespaceSelection {
export function resolveAiNamespaceSelection(tab: AiNamespaceSource, connection: ConnectionConfig): AiNamespaceSelection {
if (connection.db_type === "dameng") {
return { kind: "schema", value: tab.schema?.trim() || "" };
}
@@ -967,7 +994,7 @@ export function resolveAiNamespaceSelection(tab: QueryTab, connection: Connectio
* to match the request database (`selectedDatabases[0] ?? tab.database`), so a
* database picked in the composer wins over the query tab's own database.
*/
export function resolveAiMentionDatabase(tab: QueryTab, connection: ConnectionConfig, selectedDatabases: string[]): string {
export function resolveAiMentionDatabase(tab: AiNamespaceSource, connection: ConnectionConfig, selectedDatabases: string[]): string {
const namespace = resolveAiNamespaceSelection(tab, connection);
if (namespace.kind !== "database") return tab.database || "";
return selectedDatabases[0] ?? tab.database ?? "";
@@ -985,7 +1012,7 @@ export function resolveDefaultAiSchema(connection: ConnectionConfig, schemaOptio
* their configured database while the query tab's selection scopes metadata and
* SQL execution through the schema parameter.
*/
export function resolveAiDatabaseTarget(tab: QueryTab, connection: ConnectionConfig): { database: string; schema?: string } {
export function resolveAiDatabaseTarget(tab: AiNamespaceSource, connection: ConnectionConfig): { database: string; schema?: string } {
const database = tab.database || connection.database || "main";
if (connection.db_type === "dameng") {
return {
@@ -0,0 +1,125 @@
import type { AiConversation } from "@/lib/backend/tauri";
import type { AiContextTarget } from "@/lib/ai/ai";
import type { QueryTab } from "@/types/database";
import type { DesktopAiRunRuntime } from "@/lib/ai/desktopAiRunRegistry";
/**
* Connection a conversation talks to (#9902).
*
* The binding belongs to the conversation — not to whichever editor tab happens
* to be active — so several conversations can run against different connections
* at once. `schema` only applies to schema-scoped engines (Postgres, Dameng).
*/
export interface AiConversationBinding {
connectionId: string;
database: string;
schema?: string;
}
/** Ambient editor state, used only as the default for a chat with no binding. */
export interface AiAmbientBinding {
connectionId?: string;
database?: string;
schema?: string;
}
/**
* Binding to use for the conversation on screen.
*
* A persisted conversation always wins, *including* when its `connectionId` is
* empty: that means deliberately unbound (its stored name matched zero or
* several saved connections, or the bound connection was deleted). Falling back
* to the ambient tab there would recreate the cross-conversation leak this
* binding exists to prevent, so callers must surface the unbound state instead.
*
* Only a chat that has never been persisted — no record yet — takes `draft` (a
* connection chosen in the composer before the first message) or, failing that,
* the ambient tab.
*/
export function resolveConversationBinding(conversation: AiConversation | undefined, draft: AiConversationBinding | null, ambient: AiAmbientBinding): AiConversationBinding {
if (conversation) {
return { connectionId: conversation.connectionId, database: conversation.database, schema: conversation.schema };
}
if (draft) return draft;
return { connectionId: ambient.connectionId ?? "", database: ambient.database ?? "", schema: ambient.schema };
}
/**
* Whether the on-screen binding needs the user to pick a connection before the
* next request can target anything. True only for a *persisted* conversation
* with no connection: a brand-new chat resolves through the ambient tab instead.
*/
export function isBindingUnresolved(conversation: AiConversation | undefined, binding: AiConversationBinding): boolean {
return !!conversation && !binding.connectionId;
}
/**
* Binding to persist when snapshotting `targetConversationId`.
*
* An existing conversation keeps its own binding — writing a transcript must
* never re-derive it from the active tab, which is exactly how the binding
* became global. A chat that has not been saved yet takes `fallback`.
*/
export function bindingForSnapshot(conversations: readonly AiConversation[], targetConversationId: string, fallback: AiConversationBinding): AiConversationBinding {
const existing = conversations.find((conversation) => conversation.id === targetConversationId);
if (existing) {
return { connectionId: existing.connectionId, database: existing.database, schema: existing.schema };
}
return fallback;
}
/**
* Whether two bindings name the same target.
*
* Compares the full namespace, not just the connection: two databases (or two
* schemas) on one server are different targets, and treating them as equal would
* let a conversation bound to `prod` inherit the editor state of `test`
* (#9902 follow-up).
*/
export function sameConversationBinding(a: AiConversationBinding, b: AiConversationBinding): boolean {
return a.connectionId === b.connectionId && (a.database ?? "") === (b.database ?? "") && (a.schema ?? "") === (b.schema ?? "");
}
/** Only an executing or awaiting run owns a frozen target. An editable
* recovered draft and a terminal run must follow the conversation's current
* binding when the user sends again. */
export function activeAiRunBinding(
conversationBinding: AiConversationBinding,
run: Pick<DesktopAiRunRuntime, "connectionId" | "database" | "schema" | "status"> | undefined,
messages: readonly { role: string; sourceBinding?: AiConversationBinding }[],
foregroundActive: boolean,
): AiConversationBinding {
if (run?.connectionId && (run.status === "preparing" || run.status === "queued" || run.status === "running" || run.status === "awaiting_write_confirmation")) {
return { connectionId: run.connectionId, database: run.database, schema: run.schema };
}
if (foregroundActive) {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message.role === "assistant" && message.sourceBinding) return message.sourceBinding;
}
}
return conversationBinding;
}
/** Redis's logical DB is part of the target even when two tabs share one
* connection. A mismatch must be refused before reaching the visible console. */
export function isAiRedisConsoleTarget(tab: Pick<QueryTab, "mode" | "connectionId" | "database">, target: AiConversationBinding): boolean {
return tab.mode === "redis" && tab.connectionId === target.connectionId && tab.database === target.database;
}
/**
* Context target for a request: the conversation's namespace, plus the visible
* editor's SQL / result / focused table — but only when that tab sits on the
* *same namespace*. Another database's editor state is not context for this
* chat, and carrying it would leak one database's SQL and result rows into
* another's request.
*/
export function aiContextTargetFor(binding: AiConversationBinding, visibleTab: Pick<QueryTab, "connectionId" | "database" | "schema" | "sql" | "result" | "tableMeta"> | undefined): AiContextTarget {
const sameTarget = !!visibleTab && sameConversationBinding(binding, { connectionId: visibleTab.connectionId, database: visibleTab.database ?? "", schema: visibleTab.schema });
return {
connectionId: binding.connectionId,
database: binding.database,
schema: binding.schema,
...(sameTarget && visibleTab ? { sql: visibleTab.sql, result: visibleTab.result, tableMeta: visibleTab.tableMeta } : {}),
};
}
@@ -4,13 +4,7 @@ import type { QueryEditorTableReferenceDropDetail, QueryEditorTableReferencePayl
/** Selector marking the AI assistant panel root as a table-reference drop target. */
export const AI_ASSISTANT_TABLE_DROP_ROOT_SELECTOR = "[data-ai-assistant-root]";
export interface AiTableReferenceDropContext {
connectionId?: string;
database?: string;
}
export interface AiTableReferenceDropHandlerOptions {
context: AiTableReferenceDropContext;
assistantRoot: Element | null | undefined;
elementFromPoint: (x: number, y: number) => Element | null;
onMention: (mention: AiTableMention, payload: QueryEditorTableReferencePayload) => void;
@@ -20,10 +14,18 @@ export interface AiTableReferenceDropHandlerOptions {
* Maps a sidebar table-reference drag payload to an AI table mention chip.
* Only plain table/view references become mentions; database and column
* references are not representable as table mentions and return null.
*
* The payload's connection/database are deliberately NOT matched against a
* fixed composer context. That rejection was the right answer while the panel's
* connection was whatever editor tab was active — there was nothing to retarget,
* so a foreign table had to be dropped. Now the conversation owns its binding
* (#9902), so a table dragged in from elsewhere *retargets the conversation*
* (see `applyExternalBinding`), which is both what the drag means and what makes
* the mention resolvable. A caller that wants to refuse a foreign table must
* decide that after this function returns.
*/
export function aiTableMentionFromTableReference(payload: QueryEditorTableReferencePayload | null | undefined, context: AiTableReferenceDropContext): AiTableMention | null {
export function aiTableMentionFromTableReference(payload: QueryEditorTableReferencePayload | null | undefined): AiTableMention | null {
if (!payload || payload.referenceType === "database" || payload.columnName) return null;
if (!context.connectionId || context.database == null || payload.connectionId !== context.connectionId || payload.database !== context.database) return null;
const table = payload.tableName;
if (!table) return null;
const schema = payload.schema;
@@ -36,7 +38,7 @@ export function handleAiTableReferenceDropEvent(event: Event, options: AiTableRe
if (!detail?.payload) return false;
const target = options.elementFromPoint(detail.clientX, detail.clientY);
if (!target || !options.assistantRoot?.contains(target)) return false;
const mention = aiTableMentionFromTableReference(detail.payload, options.context);
const mention = aiTableMentionFromTableReference(detail.payload);
if (!mention) return false;
options.onMention(mention, detail.payload);
return true;
+11
View File
@@ -1185,6 +1185,8 @@ export interface AiChatMessage {
kind?: "contextSummary" | "writeSqlConfirmation" | "productionWriteBlocked";
/** Set on the assistant message whose generation failed; persisted (mirrors dbx-core `AiChatMessage.failed`). */
failed?: boolean;
/** Target frozen when this assistant turn started, retained for confirmation. */
sourceBinding?: import("@/lib/ai/aiConversationBinding").AiConversationBinding;
}
export interface AiConversation {
@@ -1192,7 +1194,16 @@ export interface AiConversation {
id: string;
title: string;
connectionName: string;
/** Connection this conversation is bound to (#9902). The binding belongs to
* the conversation, not to whichever editor tab is active.
* Empty means "unbound": either persisted before session-scoped binding
* existed and its `connectionName` matched zero or several saved connections
* (names are not unique), or the bound connection was deleted. Consumers must
* ask the user rather than fall back to the active tab. */
connectionId: string;
database: string;
/** Schema for schema-scoped engines (Postgres, Dameng); absent otherwise. */
schema?: string;
messages: AiChatMessage[];
/** One editable "send later" input saved while an active run occupies the
* conversation (parent PRD §5). Persisted with the conversation. */
+27
View File
@@ -600,6 +600,15 @@ pub struct AiStreamChunk {
pub done: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiChatSourceBinding {
pub connection_id: String,
pub database: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiChatMessage {
@@ -617,6 +626,10 @@ pub struct AiChatMessage {
pub failed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub covered_messages: Option<usize>,
/// Frozen target of the assistant turn. A Web confirmation card can remain
/// actionable after the conversation itself is rebound (#9902).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_binding: Option<AiChatSourceBinding>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -629,6 +642,20 @@ pub struct AiConversation {
pub title: String,
pub connection_name: String,
pub database: String,
/// Connection this conversation is bound to (#9902). The binding belongs to
/// the conversation — not to whatever editor tab happens to be active — so
/// several conversations can run against different connections at once.
///
/// Empty for conversations persisted before session-scoped binding existed,
/// and for legacy records whose `connection_name` matched zero or several
/// saved connections (a name is not unique). Callers must treat empty as
/// "unbound" and ask the user, never fall back to the active tab.
#[serde(default)]
pub connection_id: String,
/// Schema for schema-scoped engines (Postgres, Dameng). `None` when the
/// engine has no schema layer or the user has not picked one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
pub messages: Vec<AiChatMessage>,
/// One editable "send later" input saved while an active run occupies the
/// conversation (parent PRD §5). Persisted with the conversation so it
+222 -9
View File
@@ -908,7 +908,9 @@ const SCHEMA_STATEMENTS: &[&str] = &[
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
connection_name TEXT NOT NULL DEFAULT '',
connection_id TEXT NOT NULL DEFAULT '',
database TEXT NOT NULL DEFAULT '',
schema_name TEXT,
messages_json TEXT NOT NULL DEFAULT '[]',
queued_input TEXT,
plugin_context_json TEXT,
@@ -1968,6 +1970,9 @@ impl Storage {
ensure_state_store_columns_sync(conn)?;
ensure_ai_conversations_columns_sync(conn)?;
ensure_ai_runs_columns_sync(conn)?;
// After the column exists: bind legacy conversations to their
// connection when the stored name identifies exactly one (#9902).
backfill_ai_conversation_connections(conn)?;
Ok(())
})
}
@@ -2671,11 +2676,86 @@ fn ensure_ai_configs_columns_sync(conn: &Connection) -> Result<(), String> {
/// Adds conversation metadata columns to databases created before these fields.
fn ensure_ai_conversations_columns_sync(conn: &Connection) -> Result<(), String> {
const COLUMNS: &[(&str, &str)] = &[("queued_input", "TEXT"), ("plugin_context_json", "TEXT")];
const COLUMNS: &[(&str, &str)] = &[
("queued_input", "TEXT"),
("plugin_context_json", "TEXT"),
// Session-scoped connection binding (#9902). Existing rows keep the
// empty default and are backfilled from `connection_name` by
// [`backfill_ai_conversation_connections`].
("connection_id", "TEXT NOT NULL DEFAULT ''"),
("schema_name", "TEXT"),
];
ensure_table_columns(conn, "ai_conversations", COLUMNS)
}
/// Backfills `connection_id` for conversations persisted before session-scoped
/// binding existed (#9902).
///
/// `connection_name` is **not unique** (the import dedup key is
/// name + host + port), so only an unambiguous match may be written back: a name
/// that resolves to exactly one saved connection is bound, while zero matches
/// (connection deleted or never saved) and several matches (duplicates) stay
/// empty and surface as "unbound" in the UI. Guessing here would silently pin a
/// conversation to the wrong database, which is the defect this column exists to
/// fix.
///
/// Runs on every open but only touches rows that are still empty, so it is
/// idempotent and picks up connections imported after the last run.
fn backfill_ai_conversation_connections(conn: &Connection) -> Result<usize, String> {
let mut by_name: HashMap<String, Vec<String>> = HashMap::new();
{
let mut stmt = conn.prepare("SELECT id, config_json FROM connections").map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
.map_err(|e| e.to_string())?;
for row in rows {
let (id, json) = row.map_err(|e| e.to_string())?;
// Only the display name is needed, and it is exactly what the
// frontend persisted into `connection_name`. Reading it straight
// from the JSON avoids depending on the full `ConnectionConfig`
// deserializer (which also handles legacy shapes) for a migration.
let name = serde_json::from_str::<serde_json::Value>(&json)
.ok()
.and_then(|value| value.get("name").and_then(|name| name.as_str()).map(|name| name.trim().to_string()))
.unwrap_or_default();
if name.is_empty() {
continue;
}
by_name.entry(name).or_default().push(id);
}
}
let mut stmt = conn
.prepare("SELECT id, connection_name FROM ai_conversations WHERE connection_id = ''")
.map_err(|e| e.to_string())?;
let pending = stmt
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
drop(stmt);
let mut updated = 0usize;
for (conversation_id, connection_name) in pending {
let candidates = match by_name.get(connection_name.trim()) {
Some(candidates) => candidates,
None => continue,
};
if candidates.len() != 1 {
continue;
}
let changed = conn
.execute(
"UPDATE ai_conversations SET connection_id = ?1 WHERE id = ?2 AND connection_id = ''",
params![candidates[0], conversation_id],
)
.map_err(|e| e.to_string())?;
updated += changed;
}
Ok(updated)
}
/// Adds the background-run recovery columns (`fifo_category`, `pending_input`,
/// `max_seq`) to databases created by earlier iterations of the uncommitted
/// WIP, where the `ai_runs` table predates these fields.
@@ -4678,12 +4758,14 @@ impl Storage {
let tx = conn.transaction().map_err(|e| e.to_string())?;
tx.execute(
"INSERT INTO ai_conversations \
(id, title, connection_name, database, messages_json, queued_input, created_at, updated_at, plugin_context_json) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) \
(id, title, connection_name, connection_id, database, schema_name, messages_json, queued_input, created_at, updated_at, plugin_context_json) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(id) DO UPDATE SET \
title = excluded.title, \
connection_name = excluded.connection_name, \
connection_id = excluded.connection_id, \
database = excluded.database, \
schema_name = excluded.schema_name, \
messages_json = excluded.messages_json, \
queued_input = excluded.queued_input, \
created_at = excluded.created_at, \
@@ -4692,7 +4774,9 @@ impl Storage {
conv.id,
conv.title,
conv.connection_name,
conv.connection_id,
conv.database,
conv.schema,
messages_json,
conv.queued_input,
conv.created_at,
@@ -4712,7 +4796,7 @@ impl Storage {
self.with_conn(|conn| {
let mut stmt = conn
.prepare(
"SELECT id, title, connection_name, database, messages_json, queued_input, created_at, updated_at, plugin_context_json \
"SELECT id, title, connection_name, database, messages_json, queued_input, created_at, updated_at, plugin_context_json, connection_id, schema_name \
FROM ai_conversations ORDER BY updated_at DESC",
)
.map_err(|e| e.to_string())?;
@@ -4731,6 +4815,8 @@ impl Storage {
created_at: row.get(6)?,
updated_at: row.get(7)?,
plugin_context: row.get::<_, Option<String>>(8)?.map(|json| serde_json::from_str(&json)).transpose().map_err(map_from_sql_err)?,
connection_id: row.get(9)?,
schema: row.get(10)?,
})
})
.map_err(|e| e.to_string())?;
@@ -4797,12 +4883,14 @@ impl Storage {
let tx = conn.transaction().map_err(|e| e.to_string())?;
tx.execute(
"INSERT INTO ai_conversations
(id, title, connection_name, database, messages_json, queued_input, created_at, updated_at, plugin_context_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, title, connection_name, connection_id, database, schema_name, messages_json, queued_input, created_at, updated_at, plugin_context_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
connection_name = excluded.connection_name,
connection_id = excluded.connection_id,
database = excluded.database,
schema_name = excluded.schema_name,
messages_json = excluded.messages_json,
queued_input = excluded.queued_input,
created_at = excluded.created_at,
@@ -4811,7 +4899,9 @@ impl Storage {
conv.id,
conv.title,
conv.connection_name,
conv.connection_id,
conv.database,
conv.schema,
messages_json,
conv.queued_input,
conv.created_at,
@@ -7233,17 +7323,19 @@ impl Storage {
self.with_conn(move |conn| {
conn.execute(
"INSERT OR IGNORE INTO ai_conversations \
(id, title, connection_name, database, messages_json, created_at, updated_at, plugin_context_json) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(id, title, connection_name, connection_id, database, schema_name, messages_json, created_at, updated_at, plugin_context_json) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
conv.id,
conv.title,
conv.connection_name,
conv.connection_id,
conv.database,
conv.schema,
messages_json,
conv.created_at,
conv.updated_at,
conv.plugin_context.map(|value| value.to_string())
conv.plugin_context.map(|value| value.to_string())
],
)
.map(|_| ())
@@ -7251,6 +7343,10 @@ impl Storage {
})
.await?;
}
// Rows just imported predate the binding column (or carry a binding from
// a newer JSON export); bind the ones the stored name identifies
// unambiguously now, since `init_schema` already ran its pass (#9902).
self.with_conn(|conn| backfill_ai_conversation_connections(conn).map(|_| ())).await?;
if finalize {
tokio::fs::rename(&path, data_dir.join("ai_conversations.json.bak")).await.map_err(|e| e.to_string())?;
}
@@ -8389,7 +8485,9 @@ mod tests {
id: id.to_string(),
title: id.to_string(),
connection_name: "local".to_string(),
connection_id: "local".to_string(),
database: "db".to_string(),
schema: None,
messages: vec![AiChatMessage {
role: "user".to_string(),
content: id.to_string(),
@@ -8398,6 +8496,7 @@ mod tests {
kind: None,
failed: None,
covered_messages: None,
source_binding: None,
}],
queued_input: None,
created_at: updated_at.to_string(),
@@ -8672,6 +8771,120 @@ mod tests {
let _ = std::fs::remove_file(path);
}
/// Legacy `ai_conversations` table (no `connection_id` / `schema_name`), plus
/// a `connections` table whose rows the backfill matches against.
fn create_legacy_conversation_db(path: &std::path::Path, conversations: &str, connections: &str) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(&format!(
"CREATE TABLE ai_conversations (
id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', connection_name TEXT NOT NULL DEFAULT '',
database TEXT NOT NULL DEFAULT '', messages_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT ''
);
CREATE TABLE connections (id TEXT PRIMARY KEY, config_json TEXT NOT NULL);
{connections}
{conversations}"
))
.unwrap();
drop(conn);
}
#[tokio::test]
async fn ai_conversation_roundtrips_connection_binding() {
let path = temp_db_path("ai-conversation-binding-roundtrip");
let storage = Storage::open(&path).await.unwrap();
let mut conversation = ai_conversation("bound-conv", "0000");
conversation.connection_id = "conn-prod".to_string();
conversation.schema = Some("public".to_string());
conversation.messages[0].source_binding = Some(crate::ai::AiChatSourceBinding {
connection_id: "conn-original".to_string(),
database: "db-original".to_string(),
schema: Some("legacy".to_string()),
});
storage.save_ai_conversation(&conversation).await.unwrap();
let loaded = storage.load_ai_conversations().await.unwrap();
assert_eq!(loaded[0].connection_id, "conn-prod");
assert_eq!(loaded[0].schema.as_deref(), Some("public"));
let source = loaded[0].messages[0].source_binding.as_ref().unwrap();
assert_eq!(source.connection_id, "conn-original");
assert_eq!(source.database, "db-original");
assert_eq!(source.schema.as_deref(), Some("legacy"));
let legacy: AiChatMessage = serde_json::from_str(r#"{"role":"assistant","content":"old reply"}"#).unwrap();
assert!(legacy.source_binding.is_none());
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn ai_conversation_upgrades_legacy_schema_and_binds_a_unique_connection() {
let path = temp_db_path("ai-conversation-legacy-binding");
create_legacy_conversation_db(
&path,
"INSERT INTO ai_conversations (id, title, connection_name) VALUES ('prod', 'Prod chat', 'Prod MySQL');
INSERT INTO ai_conversations (id, title, connection_name) VALUES ('orphan', 'Orphan chat', 'Deleted Conn');",
"INSERT INTO connections (id, config_json) VALUES ('c-prod', '{\"id\":\"c-prod\",\"name\":\"Prod MySQL\",\"db_type\":\"mysql\",\"host\":\"127.0.0.1\",\"port\":3306,\"username\":\"u\",\"password\":\"p\",\"database\":null}');",
);
let storage = Storage::open(&path).await.unwrap();
let loaded = storage.load_ai_conversations().await.unwrap();
let conversation = |id: &str| loaded.iter().find(|item| item.id == id).unwrap();
// Exactly one saved connection carries the stored name: bind it.
assert_eq!(conversation("prod").connection_id, "c-prod");
// Nothing carries that name; a guess would silently point at the wrong
// database, so the conversation stays unbound for the UI to resolve.
assert!(conversation("orphan").connection_id.is_empty());
assert!(conversation("orphan").schema.is_none());
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn ai_conversation_backfill_skips_ambiguous_connection_names() {
let path = temp_db_path("ai-conversation-binding-ambiguous");
create_legacy_conversation_db(
&path,
"INSERT INTO ai_conversations (id, title, connection_name) VALUES ('dup', 'Dup chat', 'Shared Name');
INSERT INTO ai_conversations (id, title, connection_name) VALUES ('nameless', 'Nameless chat', '');",
"INSERT INTO connections (id, config_json) VALUES ('c1', '{\"id\":\"c1\",\"name\":\"Shared Name\",\"db_type\":\"mysql\",\"host\":\"127.0.0.1\",\"port\":3306,\"username\":\"u\",\"password\":\"p\",\"database\":null}');
INSERT INTO connections (id, config_json) VALUES ('c2', '{\"id\":\"c2\",\"name\":\"Shared Name\",\"db_type\":\"mysql\",\"host\":\"127.0.0.1\",\"port\":3306,\"username\":\"u\",\"password\":\"p\",\"database\":null}');",
);
let storage = Storage::open(&path).await.unwrap();
for conversation in storage.load_ai_conversations().await.unwrap() {
// Two connections share the name (names are not unique), and an empty
// name identifies nothing: neither may be auto-bound.
assert!(conversation.connection_id.is_empty(), "{} must stay unbound", conversation.id);
}
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn ai_conversation_backfill_never_overwrites_an_existing_binding() {
let path = temp_db_path("ai-conversation-binding-idempotent");
let storage = Storage::open(&path).await.unwrap();
let mut conversation = ai_conversation("pinned", "0000");
conversation.connection_id = "conn-a".to_string();
storage.save_ai_conversation(&conversation).await.unwrap();
drop(storage);
// A connection matching the stored name appears *after* the binding was
// written; re-running the backfill must leave the explicit binding alone.
let conn = Connection::open(&path).unwrap();
conn.execute("INSERT INTO connections (id, config_json) VALUES ('conn-later', '{\"id\":\"conn-later\",\"name\":\"local\",\"db_type\":\"mysql\",\"host\":\"127.0.0.1\",\"port\":3306,\"username\":\"u\",\"password\":\"p\",\"database\":null}')", [])
.unwrap();
drop(conn);
let storage = Storage::open(&path).await.unwrap();
let loaded = storage.load_ai_conversations().await.unwrap();
assert_eq!(loaded[0].connection_id, "conn-a");
let _ = std::fs::remove_file(path);
}
// Seed a backlog efficiently, then exercise the production write path that
// applies retention. Settings must affect every caller of that path.
async fn seed_history_backlog(storage: &Storage, count: usize) {
+3 -3
View File
@@ -3,7 +3,7 @@ title: AI 助手
description: 配置云端、兼容 API 或本地 CLI 模型,在 Schema 上下文中生成 SQL、分析数据并执行受控 Agent 工作流。
---
AI 助手把当前数据库、Schema、SQL、错误、结果预览和用户点名的对象组合成上下文,用于生成 SQL、解释查询、优化语句、修复错误和分析数据。
AI 助手把当前会话绑定的连接、数据库和 Schema,与编辑器 SQL、错误、结果预览和用户点名的对象组合成上下文,用于生成 SQL、解释查询、优化语句、修复错误和分析数据。每个会话各自持有绑定,使用不同连接的会话互不串用目标。
<Callout type="warn">Ask 模式只生成建议;Agent 模式在用户明确要求真实数据时可以调用查询工具。任何模型输出都可能错误。执行 SQL 前仍应检查目标连接、字段、过滤条件、参数和影响范围。</Callout>
@@ -89,7 +89,7 @@ Agent 只有在原始请求明确要求真实数据/结果时才应执行查询
AI 上下文可以包含:
- 数据库类型、连接名称、数据库和 Schema
- 当前编辑器 SQL、选中 SQL 和最近错误
- 当前编辑器 SQL、选中 SQL 和最近错误(编辑器与会话绑定在同一连接和数据库上时才纳入)
- 表、字段、索引、外键和少量样例数据
- 当前结果集的有限预览
- 用户选择的提示模板
@@ -149,7 +149,7 @@ AI Agent 的执行边界是后端强制的,不只是提示词:
## 对话、历史和导出
- 输入框支持使用 ↑ / ↓ 浏览当前提示历史
- 对话可以按连接和数据库保存,便于继续上下文
- 每个会话按自身的连接、数据库和 Schema 绑定保存,重新打开时恢复该绑定
- 每条最终 AI 分析可以导出为 Markdown,文件包含连接名、时间和最终回答
- Markdown 导出只包含最终分析内容,不会把内部 reasoning 或 Agent 步骤附加到文件中
+3 -3
View File
@@ -3,7 +3,7 @@ title: AI Assistant
description: Connect cloud, compatible API, or local CLI models to generate SQL, analyze data, and run controlled Agent workflows with schema context.
---
The AI Assistant combines the active database, schema, SQL, errors, result preview, and explicitly mentioned objects into context for SQL generation, explanation, optimization, error repair, and data analysis.
The AI Assistant combines the connection, database, and schema bound to the current conversation with editor SQL, errors, result preview, and explicitly mentioned objects into context for SQL generation, explanation, optimization, error repair, and data analysis. Each conversation keeps its own binding, so conversations on different connections never share a target.
<Callout type="warn">Ask mode produces suggestions. Agent mode can call query tools when the user explicitly requests real data. Any model output can be wrong. Review the target, fields, filters, parameters, and affected scope before executing SQL.</Callout>
@@ -89,7 +89,7 @@ Increasing the turn limit raises latency, database reads, and model cost. Prefer
AI context can include:
- Database type, connection name, database, and schema
- Current or selected editor SQL and the latest error
- Current or selected editor SQL and the latest error, once the open editor is on the conversation's own connection and database
- Tables, columns, indexes, foreign keys, and limited sample rows
- A bounded preview of the current result
- Selected prompt templates
@@ -151,7 +151,7 @@ Do not assume one UI label has identical semantics across providers.
## Conversations, History, and Export
- Use ↑ and ↓ to navigate prompt history
- Conversations can be saved with connection and database context
- Each conversation is saved with its own connection, database, and schema binding, and restores it when reopened
- Final AI analysis messages can be exported as Markdown with connection name and timestamp
- Markdown export contains the final answer only; internal reasoning and Agent steps are not appended
+5 -1
View File
@@ -51,7 +51,10 @@ function functionBody(script: string, signature: string): string {
test("send() acquires the guard synchronously before the first await", () => {
const body = functionBody(scriptSetupContent(), "async function send()");
const entryCheck = body.search(/\|\|\s*isGenerating\.value\)\s*return/);
// Anchor on the condition, not on a one-line `return`: the branch may run a
// synchronous cleanup first (a declined write-confirmation grant), so the
// shape is a block now. The assertions below still require a return inside it.
const entryCheck = body.search(/\|\|\s*isGenerating\.value\)\s*\{/);
const guardSet = body.indexOf("isGenerating.value = true");
// `await` as a whole word (not the substring inside e.g. the
// `awaiting_write_confirmation` run status), so the first suspension point is
@@ -61,6 +64,7 @@ test("send() acquires the guard synchronously before the first await", () => {
assert.notEqual(entryCheck, -1, "send() should early-return when isGenerating is already set");
assert.notEqual(guardSet, -1, "send() should set the isGenerating guard");
assert.notEqual(firstAwait, -1, "send() should contain an await");
assert.ok(body.slice(entryCheck, guardSet).includes("return"), "the isGenerating branch must return before the guard set");
// The entry check runs first, then the guard is set, then (and only then) the
// first await suspends. Because nothing awaits between the check and the set,
+8 -2
View File
@@ -75,7 +75,13 @@ test("AI request failures use localized backend diagnostics", () => {
test("AI analysis export keeps the connection that produced each assistant response", () => {
assert.match(source, /sourceConnectionName\?: string/);
assert.match(source, /const runSourceName = runPluginContext\?\.pluginName \?\? connection\?\.name \?\? ""/);
assert.match(source, /runMessages\.push\(\{ role: "assistant", content: "", sourceConnectionName: runSourceName \}\)/);
assert.match(source, /connectionName: msg\.sourceConnectionName \?\? props\.connection\?\.name/);
// The assistant placeholder must record the connection that produced it. The
// literal also carries per-run fields (the frozen `sourceBinding` target,
// #9902), so pin the pair instead of the closing brace.
assert.match(source, /runMessages\.push\(\{ role: "assistant", content: "", sourceConnectionName: runSourceName,/);
assert.match(source, /sourceBinding: runBinding \}\);/);
// The fallback for a message that predates `sourceConnectionName` is the
// conversation's own bound connection, not whatever tab is visible (#9902).
assert.match(source, /connectionName: msg\.sourceConnectionName \?\? boundConnection\.value\?\.name/);
assert.match(source, /sourceConnectionName: m\.role === "assistant" \? conv\.connectionName : undefined/);
});