feat(ai): bind each conversation to its own connection
This commit is contained in:
1 parent
47e7f56b14
commit
c373f322d2
35 files changed
+1584
-272
No files matched your search
+112
-59
@@ -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
+9
-5
@@ -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", () => {
|
||||
|
||||
+253
@@ -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");
|
||||
});
|
||||
});
|
||||
+13
-9
@@ -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"]);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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ə",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3252,6 +3252,7 @@ export default withEnglishFallback({
|
||||
truncated: "コンテキストが切り詰められました",
|
||||
contextSummary: "{database} · {tables}テーブル",
|
||||
autoSqlBlocked: "AIが生成したSQLは自動実行するにはリスクが高すぎると判断されました。実行前に手動で確認してください。",
|
||||
redisConsoleUnreachable: "AI コマンドをこの会話に紐づく Redis コンソールに送信できませんでした。その接続の Redis コンソールを開いて再試行してください。",
|
||||
writeSqlConfirmationRequired: "この SQL はデータまたはスキーマを変更します。実行前に内容をよく確認してください。",
|
||||
writeSqlConfirmationQuestion: "この SQL を一度実行しますか?",
|
||||
writeSqlConfirmYes: "実行を確認",
|
||||
|
||||
@@ -3016,6 +3016,7 @@ export default withEnglishFallback({
|
||||
truncated: "컨텍스트가 잘렸습니다",
|
||||
contextSummary: "{database} · 테이블 {tables}개",
|
||||
autoSqlBlocked: "AI가 생성한 SQL이 자동 실행하기에는 너무 위험해 보입니다. 실행 전에 수동으로 검토하세요.",
|
||||
redisConsoleUnreachable: "AI 명령을 이 대화에 바인딩된 Redis 콘솔로 전달하지 못했습니다. 해당 연결의 Redis 콘솔을 열고 다시 시도하세요.",
|
||||
writeSqlConfirmationRequired: "이 SQL은 데이터 또는 스키마를 변경합니다. 실행 전에 신중하게 검토하세요.",
|
||||
writeSqlConfirmationQuestion: "이 SQL을 한 번 실행할까요?",
|
||||
writeSqlConfirmYes: "실행 확인",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3131,6 +3131,7 @@ export default withEnglishFallback({
|
||||
truncated: "上下文已截断",
|
||||
contextSummary: "{database} · {tables} 张表",
|
||||
autoSqlBlocked: "AI 生成的 SQL 风险较高,已阻止自动执行,请手动检查后再运行。",
|
||||
redisConsoleUnreachable: "AI 命令未能送达本会话绑定的 Redis 控制台。请打开该连接的 Redis 控制台后重试。",
|
||||
writeSqlConfirmationRequired: "此 SQL 会修改数据或结构。执行前请仔细核对。",
|
||||
writeSqlConfirmationQuestion: "确认执行此 SQL 一次吗?",
|
||||
writeSqlConfirmYes: "确认执行",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+78
-12
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user