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

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-23 17:56:05 +08:00
1 parent 47e7f56b14
commit c373f322d2
35 files changed
+1584 -272

No files matched your search

+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. */