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

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

No files matched your search

@@ -20,6 +20,46 @@ describe("add to AI database context", () => {
expect(addToAiSource).toContain("Array.isArray(nodesInput) ? nodesInput : [nodesInput]");
expect(addToAiSource).toContain('entry.type === "table"');
expect(addToAiSource).toContain("for (const mention of tableMentions) handle.addTableMention(mention)");
// Every selected table still becomes a mention, and it now carries the
// binding the conversation must adopt (#9902).
expect(addToAiSource).toContain("for (const mention of tableMentions) handle.addTableMention(mention, binding)");
});
it("retargets the conversation instead of moving the workspace (#9902)", () => {
const addToAiStart = appSource.indexOf("async function addToAi");
const addToAiEnd = appSource.indexOf("function openAiPanel", addToAiStart);
const addToAiSource = appSource.slice(addToAiStart, addToAiEnd);
expect(addToAiSource).toContain("const binding: AiConversationBinding = { connectionId: node.connectionId");
// Asking about a table must not reassign the global active connection or
// switch/create an editor tab — that is what made the AI panel's target
// global.
expect(addToAiSource).not.toContain("connectionStore.activeConnectionId =");
expect(addToAiSource).not.toContain("queryStore.switchTab(");
expect(addToAiSource).not.toContain("queryStore.createTab(");
});
it("retargets even when the entry adds no table mention", () => {
// "Ask AI" on a connection or database node produces no mentions at all, so
// applying the binding only inside the mention loop would silently skip it.
const addToAiStart = appSource.indexOf("async function addToAi");
const addToAiEnd = appSource.indexOf("function openAiPanel", addToAiStart);
const addToAiSource = appSource.slice(addToAiStart, addToAiEnd);
expect(addToAiSource).toContain("void handle.bindConversation(binding);");
// The call must not live inside the mention loop.
const loopIdx = addToAiSource.indexOf("for (const mention of tableMentions)");
const bindIdx = addToAiSource.indexOf("void handle.bindConversation(binding);");
expect(bindIdx).toBeLessThan(loopIdx);
});
it("resolves the AI execution tab by the exact namespace", () => {
// `(!schema || tab.schema === schema)` used to accept a tab on ANY schema
// when the target had none, and execution then inherited that tab's schema.
const start = appSource.indexOf("function ensureQueryTabForConnection");
const body = appSource.slice(start, appSource.indexOf("queryStore.createTab", start));
expect(body).toContain("(tab.schema || undefined) === schema");
expect(body).not.toContain("!schema ||");
});
});
@@ -3,8 +3,6 @@ import { aiTableMentionFromTableReference } from "@/lib/ai/aiTableReferenceDrop"
import { createTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
describe("ai table reference drop", () => {
const context = { connectionId: "conn-1", database: "app-db" };
it("maps a table payload to a table mention", () => {
const payload = createTableReferencePayload({
connectionId: "conn-1",
@@ -14,7 +12,7 @@ describe("ai table reference drop", () => {
databaseType: "postgres",
});
expect(aiTableMentionFromTableReference(payload, context)).toEqual({
expect(aiTableMentionFromTableReference(payload)).toEqual({
raw: "@public.users",
schema: "public",
table: "users",
@@ -29,7 +27,7 @@ describe("ai table reference drop", () => {
databaseType: "mysql",
});
expect(aiTableMentionFromTableReference(payload, context)).toEqual({
expect(aiTableMentionFromTableReference(payload)).toEqual({
raw: '@"order items"',
schema: undefined,
table: "order items",
@@ -44,7 +42,7 @@ describe("ai table reference drop", () => {
databaseType: "mysql",
});
expect(aiTableMentionFromTableReference(payload, context)).toBeNull();
expect(aiTableMentionFromTableReference(payload)).toBeNull();
});
it("ignores column references", () => {
@@ -57,11 +55,11 @@ describe("ai table reference drop", () => {
databaseType: "postgres",
});
expect(aiTableMentionFromTableReference(payload, context)).toBeNull();
expect(aiTableMentionFromTableReference(payload)).toBeNull();
});
it("ignores empty payloads", () => {
expect(aiTableMentionFromTableReference(null, context)).toBeNull();
expect(aiTableMentionFromTableReference(undefined, context)).toBeNull();
expect(aiTableMentionFromTableReference(null)).toBeNull();
expect(aiTableMentionFromTableReference(undefined)).toBeNull();
});
});
@@ -0,0 +1,196 @@
import { describe, expect, it } from "vitest";
import type { AiConversation } from "@/lib/backend/tauri";
import { activeAiRunBinding, aiContextTargetFor, bindingForSnapshot, isAiRedisConsoleTarget, isBindingUnresolved, resolveConversationBinding, sameConversationBinding, type AiConversationBinding } from "@/lib/ai/aiConversationBinding";
function conversation(overrides: Partial<AiConversation> & { id: string }): AiConversation {
return {
title: overrides.id,
connectionName: "",
connectionId: "",
database: "",
messages: [],
createdAt: "2026-09-23T00:00:00.000Z",
updatedAt: "2026-09-23T00:00:00.000Z",
...overrides,
};
}
const AMBIENT = { connectionId: "conn-visible-tab", database: "ambient_db", schema: "ambient_schema" };
describe("resolveConversationBinding", () => {
it("uses the conversation's own binding, not the visible tab", () => {
const binding = resolveConversationBinding(conversation({ id: "c1", connectionId: "conn-bound", database: "bound_db", schema: "bound_schema" }), null, AMBIENT);
expect(binding).toEqual({ connectionId: "conn-bound", database: "bound_db", schema: "bound_schema" });
});
it("keeps an unbound conversation unbound instead of falling back to the visible tab", () => {
// An empty id means "deliberately unbound": the stored name matched zero or
// several saved connections, or the bound connection was deleted. Inheriting
// the active tab here is the exact leak #9902 reports.
const binding = resolveConversationBinding(conversation({ id: "c2", connectionName: "gone" }), null, AMBIENT);
expect(binding.connectionId).toBe("");
expect(binding.database).toBe("");
});
it("lets a draft binding chosen before the first message win over the tab", () => {
const draft: AiConversationBinding = { connectionId: "conn-draft", database: "draft_db" };
const binding = resolveConversationBinding(undefined, draft, AMBIENT);
expect(binding).toEqual(draft);
});
it("falls back to the ambient tab only for a chat with no persisted record", () => {
expect(resolveConversationBinding(undefined, null, AMBIENT)).toEqual({
connectionId: "conn-visible-tab",
database: "ambient_db",
schema: "ambient_schema",
});
});
it("tolerates an empty ambient tab", () => {
expect(resolveConversationBinding(undefined, null, {})).toEqual({ connectionId: "", database: "", schema: undefined });
});
});
describe("isBindingUnresolved", () => {
it("flags only a persisted conversation with no connection", () => {
expect(isBindingUnresolved(conversation({ id: "c1" }), { connectionId: "", database: "" })).toBe(true);
// A brand-new chat resolves through the ambient tab, so it is never flagged.
expect(isBindingUnresolved(undefined, { connectionId: "", database: "" })).toBe(false);
expect(isBindingUnresolved(conversation({ id: "c1", connectionId: "conn-a" }), { connectionId: "conn-a", database: "" })).toBe(false);
});
});
describe("bindingForSnapshot", () => {
it("keeps an existing conversation's binding even when the fallback points elsewhere", () => {
// Regression guard for #9902: persistence used to re-derive the connection
// from whatever tab was active, which is how one connection leaked across
// every conversation.
const conversations = [conversation({ id: "c1", connectionId: "conn-a", database: "db_a" })];
const binding = bindingForSnapshot(conversations, "c1", { connectionId: "conn-b", database: "db_b" });
expect(binding).toEqual({ connectionId: "conn-a", database: "db_a", schema: undefined });
});
it("uses the fallback for a conversation that has not been saved yet", () => {
const fallback: AiConversationBinding = { connectionId: "conn-new", database: "db_new", schema: "public" };
expect(bindingForSnapshot([], "brand-new", fallback)).toEqual(fallback);
});
it("does not resurrect a binding for another conversation's id", () => {
const conversations = [conversation({ id: "other", connectionId: "conn-a", database: "db_a" })];
expect(bindingForSnapshot(conversations, "c1", { connectionId: "conn-b", database: "db_b" }).connectionId).toBe("conn-b");
});
});
describe("aiContextTargetFor", () => {
const binding: AiConversationBinding = { connectionId: "conn-a", database: "db_a" };
it("carries the visible editor's SQL, result and focused table on the same connection", () => {
const result = { columns: ["id"], rows: [[1]] } as never;
const tableMeta = { tableName: "users", schema: "public", columns: [] } as never;
const target = aiContextTargetFor(binding, { connectionId: "conn-a", database: "db_a", sql: "select 1", result, tableMeta });
expect(target.connectionId).toBe("conn-a");
expect(target.database).toBe("db_a");
expect(target.sql).toBe("select 1");
expect(target.result).toBe(result);
expect(target.tableMeta).toBe(tableMeta);
});
it("drops the editor state of another database on the same connection", () => {
// Same server, different database: still a different target. Letting this
// through would send another database's SQL and result rows to the provider
// as "context" for a conversation bound elsewhere.
const target = aiContextTargetFor(binding, {
connectionId: "conn-a",
database: "db_b",
sql: "select * from other_db_only_table",
result: { columns: [], rows: [] } as never,
tableMeta: { tableName: "other_db_only_table", columns: [] } as never,
});
expect(target.connectionId).toBe("conn-a");
expect(target.database).toBe("db_a");
expect(target.sql).toBeUndefined();
expect(target.result).toBeUndefined();
expect(target.tableMeta).toBeUndefined();
});
it("drops another connection's editor state so its SQL cannot leak into this chat", () => {
const target = aiContextTargetFor(binding, {
connectionId: "conn-b",
database: "db_a",
sql: "delete from prod_orders",
result: { columns: [], rows: [] } as never,
tableMeta: { tableName: "prod_orders", columns: [] } as never,
});
expect(target.connectionId).toBe("conn-a");
expect(target.sql).toBeUndefined();
expect(target.result).toBeUndefined();
expect(target.tableMeta).toBeUndefined();
});
it("omits editor state when no tab is visible", () => {
expect(aiContextTargetFor(binding, undefined)).toEqual({ connectionId: "conn-a", database: "db_a", schema: undefined });
});
});
describe("sameConversationBinding", () => {
it("treats a different database or schema on the same server as a different target", () => {
const base: AiConversationBinding = { connectionId: "conn-a", database: "prod", schema: "public" };
expect(sameConversationBinding(base, { ...base })).toBe(true);
expect(sameConversationBinding(base, { ...base, database: "test" })).toBe(false);
expect(sameConversationBinding(base, { ...base, schema: "private" })).toBe(false);
expect(sameConversationBinding(base, { ...base, connectionId: "conn-b" })).toBe(false);
});
it("normalizes an absent database or schema to empty", () => {
expect(sameConversationBinding({ connectionId: "c", database: "" }, { connectionId: "c" })).toBe(true);
expect(sameConversationBinding({ connectionId: "c", database: "", schema: "" }, { connectionId: "c", database: "" })).toBe(true);
});
});
describe("activeAiRunBinding", () => {
const live: AiConversationBinding = { connectionId: "conn-b", database: "db_b", schema: "private" };
const frozen: AiConversationBinding = { connectionId: "conn-a", database: "db_a", schema: "public" };
const run = { ...frozen, status: "awaiting_write_confirmation" as const };
it("continues an awaiting desktop confirmation on its frozen target", () => {
expect(activeAiRunBinding(live, run, [], false)).toEqual(frozen);
for (const status of ["preparing", "queued", "running"] as const) {
expect(activeAiRunBinding(live, { ...run, status }, [], false)).toEqual(frozen);
}
});
it("uses the current binding for an editable recovered draft or terminal run", () => {
expect(activeAiRunBinding(live, { ...run, status: "pending_recoverable" }, [], false)).toEqual(live);
expect(activeAiRunBinding(live, { ...run, status: "completed" }, [], false)).toEqual(live);
});
it("keeps a Web proposal on the assistant message's source after rebinding", () => {
const messages = [{ role: "user" }, { role: "assistant", sourceBinding: frozen }];
expect(activeAiRunBinding(live, undefined, messages, true)).toEqual(frozen);
expect(activeAiRunBinding(live, undefined, messages, false)).toEqual(live);
});
});
describe("isAiRedisConsoleTarget", () => {
const target: AiConversationBinding = { connectionId: "redis-a", database: "1" };
it("requires the exact Redis logical database as well as the connection", () => {
expect(isAiRedisConsoleTarget({ mode: "redis", connectionId: "redis-a", database: "1" }, target)).toBe(true);
expect(isAiRedisConsoleTarget({ mode: "redis", connectionId: "redis-a", database: "0" }, target)).toBe(false);
expect(isAiRedisConsoleTarget({ mode: "redis", connectionId: "redis-b", database: "1" }, target)).toBe(false);
expect(isAiRedisConsoleTarget({ mode: "query", connectionId: "redis-a", database: "1" }, target)).toBe(false);
});
});
@@ -9,33 +9,87 @@ const redisBrowserSource = readFileSync(new URL("../../../components/redis/Redis
describe("AI Redis console routing", () => {
it("routes Redis insert and execute actions to the active Redis console", () => {
expect(aiAssistantSource).toContain('emit("insertRedisCommand", code)');
expect(aiAssistantSource).toContain('emit("executeRedisCommand", code)');
// Every action carries the conversation's bound connection (#9902); the
// console refuses a target that is not the visible tab.
expect(aiAssistantSource).toContain('emit("insertRedisCommand", code, conversationBinding.value)');
expect(aiAssistantSource).toContain('emit("executeRedisCommand", code, conversationBinding.value)');
expect(aiAssistantSource).toContain("seg.isSql || isRedisConnection");
expect(appSource).toContain("if (routeAiRedisCommand(sql, false)) return;");
expect(appSource).toContain("if (routeAiRedisCommand(sql, true)) return;");
expect(contentAreaSource).toContain('props.activeTab.mode !== "redis"');
expect(appSource).toContain("if (routeAiRedisCommand(sql, false, target)) return;");
expect(appSource).toContain("if (routeAiRedisCommand(sql, true, target)) return;");
expect(contentAreaSource).toContain("if (!isRedisConsoleReady(target)) return false;");
expect(contentAreaSource).toContain("redisKeyBrowserRef.value?.executeCommand?.(command)");
});
it("keeps the existing SQL editor and execution behavior for non-Redis connections", () => {
expect(aiAssistantSource).toContain('emit("appendSql", code)');
expect(aiAssistantSource).toContain('emit("executeSql", code)');
expect(aiAssistantSource).toContain('emit("tempRunSql", code)');
expect(appSource).toContain("const tabId = ensureQueryTab();");
expect(aiAssistantSource).toContain('emit("appendSql", code, conversationBinding.value)');
expect(aiAssistantSource).toContain('emit("executeSql", code, conversationBinding.value)');
expect(aiAssistantSource).toContain('emit("tempRunSql", code, conversationBinding.value)');
// The target tab is resolved from the bound connection, never from the tab
// that happens to be active (#9902).
expect(appSource).toContain("const tabId = ensureQueryTabForConnection(target);");
expect(appSource).not.toContain("function ensureQueryTab()");
expect(appSource).toContain("buildDeduplicatedAppendedEditorSql(currentSql, sql)");
expect(appSource).toContain('buildAppendedEditorSql(activeTab.value?.sql || "", sql)');
expect(appSource).toContain("const decision = classifyAiSqlExecution(sql, activeConnection.value);");
expect(appSource).toContain("buildAppendedEditorSql(aiTargetTabSql(tabId), sql)");
expect(appSource).toContain("const decision = classifyAiSqlExecution(sql, connection);");
});
it("deduplicates immediate-run editor writes without skipping execution", () => {
const handler = appSource.match(/function onAiExecuteSql\(sql: string\) \{[\s\S]*?\n\}/)?.[0] ?? "";
const handler = appSource.match(/function onAiExecuteSql\(sql: string, target: AiConversationBinding\) \{[\s\S]*?\n\}/)?.[0] ?? "";
expect(handler).toContain("buildDeduplicatedAppendedEditorSql(currentSql, sql)");
expect(handler).toContain("if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql);");
expect(handler).toContain("runAiGeneratedSql(sql, tabId);");
expect(handler).not.toContain("buildAppendedEditorSql(");
});
it("refuses a Redis command rather than moving the workspace to the bound console", () => {
// "An AI action never moves the workspace" is the point of #9902. The bound
// console is driven only when it is already the visible tab; otherwise the
// user is told to open it. No tab is switched and none is created.
const start = appSource.indexOf("function routeAiRedisCommand");
const route = appSource.slice(start, appSource.indexOf("function aiTargetTabSql", start));
expect(route).not.toContain("switchTab");
expect(route).not.toContain("createTab");
expect(route).not.toContain("ensureRedisConsoleTab");
expect(appSource).not.toContain("ensureRedisConsoleTab");
expect(route).toContain("void deliverRedisAiCommand(command, execute, target);");
// The refusal is reported, not silent.
expect(appSource).toContain('toast(t("ai.redisConsoleUnreachable"), 5000);');
});
it("still drives the bound console when it is the visible tab", () => {
// The refusal above must not disable the working case: readiness means the
// on-screen console belongs to the bound connection.
expect(contentAreaSource).toContain("isAiRedisConsoleTarget(props.activeTab, target)");
const start = appSource.indexOf("async function deliverRedisAiCommand");
const deliver = appSource.slice(start, appSource.indexOf("function aiTargetTabSql", start));
expect(deliver).toContain("contentAreaRef.value?.executeRedisCommand(command, target)");
expect(deliver).toContain("contentAreaRef.value?.insertRedisCommand(command, target)");
});
it("waits for the console to mount without re-issuing the command", () => {
const start = appSource.indexOf("async function deliverRedisAiCommand");
expect(start).toBeGreaterThanOrEqual(0);
const deliver = appSource.slice(start, appSource.indexOf("\n}", start));
// Readiness is polled through a side-effect-free probe...
const probeIdx = deliver.indexOf("isRedisConsoleReady(target)");
expect(probeIdx).toBeGreaterThanOrEqual(0);
expect(deliver).toContain("REDIS_CONSOLE_READY_TIMEOUT_MS");
// ...and the single route attempt happens only after the wait, so a command
// that ran but reported false (e.g. awaiting confirmation) cannot run twice.
const routeIdx = deliver.indexOf("executeRedisCommand(command, target)");
expect(routeIdx).toBeGreaterThan(probeIdx);
// The probe itself must not execute anything.
const probeStart = contentAreaSource.indexOf("function isRedisConsoleReady");
expect(probeStart).toBeGreaterThanOrEqual(0);
const probe = contentAreaSource.slice(probeStart, contentAreaSource.indexOf("\n}", probeStart));
expect(probe).toContain("isAiRedisConsoleTarget(props.activeTab, target) && !!redisKeyBrowserRef.value");
expect(probe).not.toContain("executeCommand");
expect(probe).not.toContain("insertCommand");
});
it("uses the console safety path and rejects unavailable command input", () => {
expect(classifyRedisCommandSafety("CONFIG SET requirepass secret")).toBe("blocked");
expect(classifyRedisCommandSafety("FLUSHDB")).toBe("confirm");
@@ -63,4 +117,16 @@ describe("AI Redis console routing", () => {
expect(anyBlocked).toBe(true);
}
});
it("tells the user when the bound Redis console cannot be reached", () => {
// A console.warn is invisible in a desktop app, and the command the user
// asked for is not going to run — silence is the worst outcome.
// Sliced between two anchors: writing an escaped newline in a source anchor
// is fragile, and this pair is unambiguous.
const start = appSource.indexOf("async function deliverRedisAiCommand");
const deliver = appSource.slice(start, appSource.indexOf("function aiTargetTabSql", start));
expect(deliver).toContain('toast(t("ai.redisConsoleUnreachable"), 5000);');
// Both failure paths (mount timeout, command rejected) report.
expect(deliver.split('toast(t("ai.redisConsoleUnreachable")').length - 1).toBe(2);
});
});
+36 -9
View File
@@ -746,8 +746,35 @@ function formatAttachedTextData(context: AiContext, isZh: boolean): string {
].join("\n\n");
}
/** The namespace-defining subset of a query tab: what identifies the database /
* schema an AI request targets. A `QueryTab` satisfies this structurally, so
* editor-tab callers are unaffected; the AI panel passes a conversation-bound
* target instead (#9902). */
export interface AiNamespaceSource {
database?: string;
schema?: string;
}
/** Everything `buildAiContext` needs to describe what the AI is looking at.
*
* A `QueryTab` satisfies this structurally, so passing one still works. The AI
* panel passes a *conversation-bound* target instead: the namespace must follow
* the conversation, not whichever editor tab happens to be active, otherwise
* two conversations share one connection (#9902).
*
* `sql` / `result` / `tableMeta` describe the editor the user is looking at, so
* the panel only attaches them when that tab is on the bound connection —
* another connection's SQL and results are not context for this conversation.
*/
export interface AiContextTarget extends AiNamespaceSource {
connectionId: string;
sql?: string;
result?: QueryResult;
tableMeta?: QueryTab["tableMeta"];
}
export async function buildAiContext(
tab: QueryTab,
tab: AiContextTarget,
connection: ConnectionConfig,
options: { maxTables?: number; maxColumnsPerTable?: number; maxIndexesPerTable?: number; maxFksPerTable?: number; mentionedTables?: AiTableMention[]; sqlFiles?: AiSqlFileContext[]; csvFiles?: AiCsvFileContext[] } = {},
): Promise<AiContext> {
@@ -873,7 +900,7 @@ export async function buildAiContext(
databaseType,
database,
schema,
currentSql: currentCollectionName ?? tab.sql,
currentSql: currentCollectionName ?? tab.sql ?? "",
lastError: extractLastError(tab.result),
lastResultPreview: formatResultPreview(tab.result),
tables,
@@ -884,7 +911,7 @@ export async function buildAiContext(
};
}
async function loadMentionedTableContext(tab: QueryTab, connection: ConnectionConfig, mention: AiTableMention, maxColumnsPerTable: number, maxIndexesPerTable: number, maxFksPerTable: number): Promise<AiSchemaTable | undefined> {
async function loadMentionedTableContext(tab: AiContextTarget, connection: ConnectionConfig, mention: AiTableMention, maxColumnsPerTable: number, maxIndexesPerTable: number, maxFksPerTable: number): Promise<AiSchemaTable | undefined> {
const databaseType = aiDatabaseTypeForConnection(connection);
const database = aiDatabaseNamespace(tab, connection);
const schema = await resolveMentionedTableSchema(tab, connection, mention);
@@ -910,7 +937,7 @@ async function loadTableComment(connectionId: string, database: string, schema:
return tables.find((table) => table.name.toLowerCase() === tableName.toLowerCase())?.comment?.trim() || undefined;
}
async function resolveMentionedTableSchema(tab: QueryTab, connection: ConnectionConfig, mention: AiTableMention): Promise<string> {
async function resolveMentionedTableSchema(tab: AiContextTarget, connection: ConnectionConfig, mention: AiTableMention): Promise<string> {
if (mention.schema) return mention.schema;
if (tab.tableMeta?.tableName.toLowerCase() === mention.table.toLowerCase() && tab.tableMeta.schema) {
return tab.tableMeta.schema;
@@ -926,7 +953,7 @@ async function resolveMentionedTableSchema(tab: QueryTab, connection: Connection
return aiDatabaseNamespace(tab, connection);
}
async function loadCandidateSchemas(tab: QueryTab, connection: ConnectionConfig): Promise<string[]> {
async function loadCandidateSchemas(tab: AiContextTarget, connection: ConnectionConfig): Promise<string[]> {
const { database, schema } = resolveAiDatabaseTarget(tab, connection);
if (schema) return [schema];
if (isSchemaAware(aiDatabaseTypeForConnection(connection))) {
@@ -951,11 +978,11 @@ export function aiSchemaSelectionSupported(connection: ConnectionConfig): boolea
return isSchemaAware(aiDatabaseTypeForConnection(connection));
}
function aiDatabaseNamespace(tab: QueryTab, connection: ConnectionConfig): string {
function aiDatabaseNamespace(tab: AiNamespaceSource, connection: ConnectionConfig): string {
return resolveAiDatabaseTarget(tab, connection).database;
}
export function resolveAiNamespaceSelection(tab: QueryTab, connection: ConnectionConfig): AiNamespaceSelection {
export function resolveAiNamespaceSelection(tab: AiNamespaceSource, connection: ConnectionConfig): AiNamespaceSelection {
if (connection.db_type === "dameng") {
return { kind: "schema", value: tab.schema?.trim() || "" };
}
@@ -967,7 +994,7 @@ export function resolveAiNamespaceSelection(tab: QueryTab, connection: Connectio
* to match the request database (`selectedDatabases[0] ?? tab.database`), so a
* database picked in the composer wins over the query tab's own database.
*/
export function resolveAiMentionDatabase(tab: QueryTab, connection: ConnectionConfig, selectedDatabases: string[]): string {
export function resolveAiMentionDatabase(tab: AiNamespaceSource, connection: ConnectionConfig, selectedDatabases: string[]): string {
const namespace = resolveAiNamespaceSelection(tab, connection);
if (namespace.kind !== "database") return tab.database || "";
return selectedDatabases[0] ?? tab.database ?? "";
@@ -985,7 +1012,7 @@ export function resolveDefaultAiSchema(connection: ConnectionConfig, schemaOptio
* their configured database while the query tab's selection scopes metadata and
* SQL execution through the schema parameter.
*/
export function resolveAiDatabaseTarget(tab: QueryTab, connection: ConnectionConfig): { database: string; schema?: string } {
export function resolveAiDatabaseTarget(tab: AiNamespaceSource, connection: ConnectionConfig): { database: string; schema?: string } {
const database = tab.database || connection.database || "main";
if (connection.db_type === "dameng") {
return {
@@ -0,0 +1,125 @@
import type { AiConversation } from "@/lib/backend/tauri";
import type { AiContextTarget } from "@/lib/ai/ai";
import type { QueryTab } from "@/types/database";
import type { DesktopAiRunRuntime } from "@/lib/ai/desktopAiRunRegistry";
/**
* Connection a conversation talks to (#9902).
*
* The binding belongs to the conversation — not to whichever editor tab happens
* to be active — so several conversations can run against different connections
* at once. `schema` only applies to schema-scoped engines (Postgres, Dameng).
*/
export interface AiConversationBinding {
connectionId: string;
database: string;
schema?: string;
}
/** Ambient editor state, used only as the default for a chat with no binding. */
export interface AiAmbientBinding {
connectionId?: string;
database?: string;
schema?: string;
}
/**
* Binding to use for the conversation on screen.
*
* A persisted conversation always wins, *including* when its `connectionId` is
* empty: that means deliberately unbound (its stored name matched zero or
* several saved connections, or the bound connection was deleted). Falling back
* to the ambient tab there would recreate the cross-conversation leak this
* binding exists to prevent, so callers must surface the unbound state instead.
*
* Only a chat that has never been persisted — no record yet — takes `draft` (a
* connection chosen in the composer before the first message) or, failing that,
* the ambient tab.
*/
export function resolveConversationBinding(conversation: AiConversation | undefined, draft: AiConversationBinding | null, ambient: AiAmbientBinding): AiConversationBinding {
if (conversation) {
return { connectionId: conversation.connectionId, database: conversation.database, schema: conversation.schema };
}
if (draft) return draft;
return { connectionId: ambient.connectionId ?? "", database: ambient.database ?? "", schema: ambient.schema };
}
/**
* Whether the on-screen binding needs the user to pick a connection before the
* next request can target anything. True only for a *persisted* conversation
* with no connection: a brand-new chat resolves through the ambient tab instead.
*/
export function isBindingUnresolved(conversation: AiConversation | undefined, binding: AiConversationBinding): boolean {
return !!conversation && !binding.connectionId;
}
/**
* Binding to persist when snapshotting `targetConversationId`.
*
* An existing conversation keeps its own binding — writing a transcript must
* never re-derive it from the active tab, which is exactly how the binding
* became global. A chat that has not been saved yet takes `fallback`.
*/
export function bindingForSnapshot(conversations: readonly AiConversation[], targetConversationId: string, fallback: AiConversationBinding): AiConversationBinding {
const existing = conversations.find((conversation) => conversation.id === targetConversationId);
if (existing) {
return { connectionId: existing.connectionId, database: existing.database, schema: existing.schema };
}
return fallback;
}
/**
* Whether two bindings name the same target.
*
* Compares the full namespace, not just the connection: two databases (or two
* schemas) on one server are different targets, and treating them as equal would
* let a conversation bound to `prod` inherit the editor state of `test`
* (#9902 follow-up).
*/
export function sameConversationBinding(a: AiConversationBinding, b: AiConversationBinding): boolean {
return a.connectionId === b.connectionId && (a.database ?? "") === (b.database ?? "") && (a.schema ?? "") === (b.schema ?? "");
}
/** Only an executing or awaiting run owns a frozen target. An editable
* recovered draft and a terminal run must follow the conversation's current
* binding when the user sends again. */
export function activeAiRunBinding(
conversationBinding: AiConversationBinding,
run: Pick<DesktopAiRunRuntime, "connectionId" | "database" | "schema" | "status"> | undefined,
messages: readonly { role: string; sourceBinding?: AiConversationBinding }[],
foregroundActive: boolean,
): AiConversationBinding {
if (run?.connectionId && (run.status === "preparing" || run.status === "queued" || run.status === "running" || run.status === "awaiting_write_confirmation")) {
return { connectionId: run.connectionId, database: run.database, schema: run.schema };
}
if (foregroundActive) {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message.role === "assistant" && message.sourceBinding) return message.sourceBinding;
}
}
return conversationBinding;
}
/** Redis's logical DB is part of the target even when two tabs share one
* connection. A mismatch must be refused before reaching the visible console. */
export function isAiRedisConsoleTarget(tab: Pick<QueryTab, "mode" | "connectionId" | "database">, target: AiConversationBinding): boolean {
return tab.mode === "redis" && tab.connectionId === target.connectionId && tab.database === target.database;
}
/**
* Context target for a request: the conversation's namespace, plus the visible
* editor's SQL / result / focused table — but only when that tab sits on the
* *same namespace*. Another database's editor state is not context for this
* chat, and carrying it would leak one database's SQL and result rows into
* another's request.
*/
export function aiContextTargetFor(binding: AiConversationBinding, visibleTab: Pick<QueryTab, "connectionId" | "database" | "schema" | "sql" | "result" | "tableMeta"> | undefined): AiContextTarget {
const sameTarget = !!visibleTab && sameConversationBinding(binding, { connectionId: visibleTab.connectionId, database: visibleTab.database ?? "", schema: visibleTab.schema });
return {
connectionId: binding.connectionId,
database: binding.database,
schema: binding.schema,
...(sameTarget && visibleTab ? { sql: visibleTab.sql, result: visibleTab.result, tableMeta: visibleTab.tableMeta } : {}),
};
}
@@ -4,13 +4,7 @@ import type { QueryEditorTableReferenceDropDetail, QueryEditorTableReferencePayl
/** Selector marking the AI assistant panel root as a table-reference drop target. */
export const AI_ASSISTANT_TABLE_DROP_ROOT_SELECTOR = "[data-ai-assistant-root]";
export interface AiTableReferenceDropContext {
connectionId?: string;
database?: string;
}
export interface AiTableReferenceDropHandlerOptions {
context: AiTableReferenceDropContext;
assistantRoot: Element | null | undefined;
elementFromPoint: (x: number, y: number) => Element | null;
onMention: (mention: AiTableMention, payload: QueryEditorTableReferencePayload) => void;
@@ -20,10 +14,18 @@ export interface AiTableReferenceDropHandlerOptions {
* Maps a sidebar table-reference drag payload to an AI table mention chip.
* Only plain table/view references become mentions; database and column
* references are not representable as table mentions and return null.
*
* The payload's connection/database are deliberately NOT matched against a
* fixed composer context. That rejection was the right answer while the panel's
* connection was whatever editor tab was active — there was nothing to retarget,
* so a foreign table had to be dropped. Now the conversation owns its binding
* (#9902), so a table dragged in from elsewhere *retargets the conversation*
* (see `applyExternalBinding`), which is both what the drag means and what makes
* the mention resolvable. A caller that wants to refuse a foreign table must
* decide that after this function returns.
*/
export function aiTableMentionFromTableReference(payload: QueryEditorTableReferencePayload | null | undefined, context: AiTableReferenceDropContext): AiTableMention | null {
export function aiTableMentionFromTableReference(payload: QueryEditorTableReferencePayload | null | undefined): AiTableMention | null {
if (!payload || payload.referenceType === "database" || payload.columnName) return null;
if (!context.connectionId || context.database == null || payload.connectionId !== context.connectionId || payload.database !== context.database) return null;
const table = payload.tableName;
if (!table) return null;
const schema = payload.schema;
@@ -36,7 +38,7 @@ export function handleAiTableReferenceDropEvent(event: Event, options: AiTableRe
if (!detail?.payload) return false;
const target = options.elementFromPoint(detail.clientX, detail.clientY);
if (!target || !options.assistantRoot?.contains(target)) return false;
const mention = aiTableMentionFromTableReference(detail.payload, options.context);
const mention = aiTableMentionFromTableReference(detail.payload);
if (!mention) return false;
options.onMention(mention, detail.payload);
return true;
+11
View File
@@ -1185,6 +1185,8 @@ export interface AiChatMessage {
kind?: "contextSummary" | "writeSqlConfirmation" | "productionWriteBlocked";
/** Set on the assistant message whose generation failed; persisted (mirrors dbx-core `AiChatMessage.failed`). */
failed?: boolean;
/** Target frozen when this assistant turn started, retained for confirmation. */
sourceBinding?: import("@/lib/ai/aiConversationBinding").AiConversationBinding;
}
export interface AiConversation {
@@ -1192,7 +1194,16 @@ export interface AiConversation {
id: string;
title: string;
connectionName: string;
/** Connection this conversation is bound to (#9902). The binding belongs to
* the conversation, not to whichever editor tab is active.
* Empty means "unbound": either persisted before session-scoped binding
* existed and its `connectionName` matched zero or several saved connections
* (names are not unique), or the bound connection was deleted. Consumers must
* ask the user rather than fall back to the active tab. */
connectionId: string;
database: string;
/** Schema for schema-scoped engines (Postgres, Dameng); absent otherwise. */
schema?: string;
messages: AiChatMessage[];
/** One editable "send later" input saved while an active run occupies the
* conversation (parent PRD §5). Persisted with the conversation. */