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
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;
|
||||
|
||||
Reference in new issue
Block a user