fix(ai): report ai sql action results and errors in pane

This commit is contained in:
Abeautifulsnow
2026-10-14 20:46:20 +08:00
committed by GitHub
parent f201de8f40
commit 5a67c5dbd6
24 changed files with 1097 additions and 52 deletions
+178 -13
View File
@@ -162,6 +162,9 @@ import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
import { isShortcutEditingKeydown, isShortcutEditingTarget } from "@/lib/editor/shortcutRecorder";
import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities";
import { classifyAiSqlExecution } from "@/lib/ai/aiSqlExecutionPolicy";
import { aiSqlActionFeedback, type AiSqlActionOutcome, type AiSqlActionReport } from "@/lib/ai/aiSqlActionFeedback";
import type { SqlExecutionOutcome } from "@/lib/sql/sqlExecutionOutcome";
import { tabDisplayTitle } from "@/lib/tabs/tabPresentation";
import { buildAppendedEditorSql, buildDeduplicatedAppendedEditorSql } from "@/lib/ai/aiSqlAppend";
import { assessProductionSql } from "@/lib/database/productionSafety";
import { normalizeSqlExecutionTarget, sqlExecutionTargetCapabilities } from "@/lib/database/sqlExecutionTargetCapabilities";
@@ -237,6 +240,12 @@ type AiAssistantHandle = {
focusSearch: () => boolean;
/** Opens a conversation by id (used by the background-run toast, §9). */
selectConversationById: (conversationId: string) => void;
/**
* Settlement of an AI-initiated SQL action (#11962). The panel shows the
* outcome inside the SQL block that started it, because the target editor tab
* may not be on screen (#9902 keeps AI actions from moving the workspace).
*/
reportAiSqlAction: (report: AiSqlActionReport) => void;
};
type AuxiliarySearchSurface = "ai" | "history" | "sqlLibrary" | null;
@@ -1050,6 +1059,9 @@ const {
requestDangerConfirmation: (request) => sqlExecutionDangerStore.requestConfirmation(request),
onExecutionStarted: (editorViewportRequestId) => contentAreaRef.value?.acceptQueryEditorExecutionViewport(editorViewportRequestId),
onExecutionCancelled: (editorViewportRequestId) => contentAreaRef.value?.cancelQueryEditorExecutionViewport(editorViewportRequestId),
// A dialog-parked execution reports its real outcome here, not from tryExecute
// (#11962): that call returned as soon as the dialog opened.
onDeferredExecutionSettled: (outcome) => settleDeferredAiSqlAction(outcome),
});
function captureActiveEditorExecutionSnapshot(tabId: string) {
@@ -3631,34 +3643,187 @@ 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;
/**
* Whether an AI action's feedback has to come from the panel instead of the tab.
*
* Deliberately *conservative* against the `v-show` on the editor content
* (`isSpecialPageActive || (!isAiPanelMaximized && !isHistoryPanelMaximized) ||
* isZenMode`): any state that hides the workspace counts as not rendered. Zen
* mode is the one divergence — it renders the workspace while `isAiPanelMaximized`
* can still be latched, and the AI panel itself is hidden in zen, so reporting the
* toast there is the right call rather than a spare one. This can only ever
* produce an extra notice; it never hides one.
*/
function workspaceRendered(): boolean {
return !isAiPanelMaximized.value && !isHistoryPanelMaximized.value && !isSpecialPageActive.value;
}
/**
* Whether an AI action's target tab is actually on screen (#11962).
*
* "On screen" means the workspace renders *and* the tab is the active tab of
* some editor group. Every group renders (SqlEditorWorkspace.vue), so a
* non-focused split group's active tab counts — same idiom as
* `SqlEditorWorkspace.editorGroupForTab`.
*/
function aiTargetTabOnScreen(tabId: string): boolean {
return workspaceRendered() && queryStore.groups.some((group) => group.activeTabId === tabId);
}
function aiTargetTabTitle(tabId: string): string {
const tab = queryStore.tabs.find((candidate) => candidate.id === tabId);
return tab ? tabDisplayTitle(tab, t) : tabId;
}
/** Rows returned or affected by the target tab's current result, for the panel summary. */
function aiTargetResultCounts(tabId: string): { rows?: number; affectedRows?: number } {
const result = queryStore.tabs.find((tab) => tab.id === tabId)?.result;
if (!result || result.execution_error === true) return {};
return {
...(result.rows.length ? { rows: result.rows.length } : {}),
...(result.affected_rows ? { affectedRows: result.affected_rows } : {}),
};
}
/**
* Outcome reasons that mean the user was already shown something (a danger or
* production dialog, the SQL-parameter dialog, the read-only unlock prompt, the
* blocked-Redis toast, the "select a database" toast) and dismissed it. The
* panel then only stops its "running" state — no second report.
*/
const DISMISSED_OUTCOME_REASONS = new Set(["cancelled", "parameters", "blocked", "missing-database"]);
/**
* AI actions waiting for a dialog-parked execution (#11962).
*
* Keyed by tab because the resumed execution identifies itself by tab only, and
* the parameter / danger dialogs are modal, so one pending entry per tab is
* enough; a second click on the same tab simply takes over the entry.
*/
const deferredAiSqlActions = new Map<string, number>();
function rememberDeferredAiSqlAction(tabId: string, requestId: number | undefined): void {
if (requestId !== undefined) deferredAiSqlActions.set(tabId, requestId);
}
/**
* Settlement of a dialog-parked execution. Executions started from the editor
* reach this too — they have no entry, and the editor owns its own feedback — so
* an unknown tab is deliberately ignored.
*/
function settleDeferredAiSqlAction(outcome: SqlExecutionOutcome): void {
const tabId = outcome.tabId;
const requestId = tabId ? deferredAiSqlActions.get(tabId) : undefined;
if (tabId) deferredAiSqlActions.delete(tabId);
if (requestId === undefined) return;
settleAiSqlAction(requestId, outcome);
}
/**
* Reports an AI action's settlement to the panel and, when the target tab is not
* on screen, raises the toast plus `[查看]` action that gets the user there.
*
* Feedback lives here because the editor cannot deliver it: the target tab is
* created without activation (#9902) and may be hidden behind another tab, a
* maximized panel, or a settings page. Never activates the tab itself — only the
* `[查看]` click does.
*/
function settleAiSqlAction(requestId: number | undefined, outcome: AiSqlActionOutcome): void {
const tabId = outcome.tabId;
if (outcome.status === "deferred") {
// Parked on a parameter / danger / Redis dialog. The block keeps its "running"
// state — the click still shows something — and nothing is reported yet: the
// execution has not run, so the resumed one settles it through
// `onDeferredExecutionSettled`.
rememberDeferredAiSqlAction(outcome.tabId, requestId);
return;
}
const targetOnScreen = !!tabId && aiTargetTabOnScreen(tabId);
const feedback = aiSqlActionFeedback(outcome, { targetOnScreen, tabTitle: tabId ? aiTargetTabTitle(tabId) : undefined });
const feedbackText = feedback.level === "none" ? undefined : t(feedback.messageKey, feedback.params);
if (requestId !== undefined && aiAssistantRef.value) {
const report: AiSqlActionReport = (() => {
if (outcome.status === "applied") return { requestId, status: "applied" };
if (outcome.status === "not-started") {
// A dismissal (dialog answered) has nothing to say; anything else keeps the
// reason in the block, where it outlives the toast.
return DISMISSED_OUTCOME_REASONS.has(outcome.reason) ? { requestId, status: "dismissed" as const } : { requestId, status: "unavailable" as const, ...(feedbackText ? { message: feedbackText } : {}) };
}
return {
requestId,
status: outcome.ok ? ("succeeded" as const) : ("failed" as const),
...(outcome.ok ? aiTargetResultCounts(outcome.tabId) : { message: outcome.errorMessage }),
};
})();
aiAssistantRef.value.reportAiSqlAction(report);
}
if (feedback.level === "none" || feedbackText === undefined) return;
const revealTabId = feedback.revealTabId;
// `[查看]` is the only path that moves the workspace (#9902): the AI action
// itself never activates, selects, or creates anything the user did not ask for.
const reveal = revealTabId ? { label: t("ai.sqlActionViewTab"), onClick: () => activateQueryTab(revealTabId) } : undefined;
toast(feedbackText, 5000, reveal);
}
function onAiAppendSql(sql: string, target: AiConversationBinding, requestId?: number) {
if (routeAiRedisCommand(sql, false, target)) {
// Redis keeps its own feedback (`ai.redisConsoleUnreachable`); this only
// stops the block's "running" state, which no editor tab can settle.
settleAiSqlAction(requestId, { status: "not-started", reason: "blocked" });
return;
}
const tabId = ensureQueryTabForConnection(target);
if (!tabId) return;
if (!tabId) {
settleAiSqlAction(requestId, { status: "not-started", reason: "no-connection" });
return;
}
const currentSql = aiTargetTabSql(tabId);
const appendedSql = buildDeduplicatedAppendedEditorSql(currentSql, sql);
if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql);
settleAiSqlAction(requestId, { status: "applied", tabId });
}
function runAiGeneratedSql(sql: string, tabId: string) {
if (!tabId) return;
function runAiGeneratedSql(sql: string, tabId: string, requestId?: number): Promise<void> {
if (!tabId) {
settleAiSqlAction(requestId, { status: "not-started", reason: "no-connection" });
return Promise.resolve();
}
selectedSql.value = "";
nextTick(() => tryExecute(sql, { tabId }));
return nextTick(async () => {
try {
settleAiSqlAction(requestId, await tryExecute(sql, { tabId }));
} catch (error) {
// Execution threw before it could report. Settle as a failure anyway: a
// request that never comes back must not leave the block spinning.
settleAiSqlAction(requestId, { status: "started", ok: false, errorMessage: translateBackendError(t, error), tabId });
}
});
}
function onAiExecuteSql(sql: string, target: AiConversationBinding) {
if (routeAiRedisCommand(sql, true, target)) return;
function onAiExecuteSql(sql: string, target: AiConversationBinding, requestId?: number) {
if (routeAiRedisCommand(sql, true, target)) {
settleAiSqlAction(requestId, { status: "not-started", reason: "blocked" });
return;
}
const tabId = ensureQueryTabForConnection(target);
if (!tabId) return;
if (!tabId) {
settleAiSqlAction(requestId, { status: "not-started", reason: "no-connection" });
return;
}
const currentSql = aiTargetTabSql(tabId);
const appendedSql = buildDeduplicatedAppendedEditorSql(currentSql, sql);
if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql);
runAiGeneratedSql(sql, tabId);
void runAiGeneratedSql(sql, tabId, requestId);
}
function onAiTempRunSql(sql: string, target: AiConversationBinding) {
if (routeAiRedisCommand(sql, true, target)) return;
runAiGeneratedSql(sql, ensureQueryTabForConnection(target));
function onAiTempRunSql(sql: string, target: AiConversationBinding, requestId?: number) {
if (routeAiRedisCommand(sql, true, target)) {
settleAiSqlAction(requestId, { status: "not-started", reason: "blocked" });
return;
}
void runAiGeneratedSql(sql, ensureQueryTabForConnection(target), requestId);
}
function onAiRequestAutoExecuteSql(sql: string, target: AiConversationBinding) {
@@ -203,6 +203,7 @@ import { databaseManifestEntry } from "@/lib/database/databaseDriverManifest";
import type { AgentEvent } from "@/lib/backend/tauri";
import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan";
import { extractFirstSqlCodeBlock, extractSingleSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy";
import type { AiSqlActionReport } from "@/lib/ai/aiSqlActionFeedback";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { buildAiAgentStepItems, formatAgentToolName, formatToolDurationMs, toolCallStepKey, updateAgentStepApproval, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation";
@@ -393,9 +394,9 @@ const props = defineProps<{
// active tab, so AI-generated SQL could be written into, and executed on,
// another connection's editor.
const emit = defineEmits<{
appendSql: [sql: string, target: AiConversationBinding];
executeSql: [sql: string, target: AiConversationBinding];
tempRunSql: [sql: string, target: AiConversationBinding];
appendSql: [sql: string, target: AiConversationBinding, requestId?: number];
executeSql: [sql: string, target: AiConversationBinding, requestId?: number];
tempRunSql: [sql: string, target: AiConversationBinding, requestId?: number];
requestAutoExecuteSql: [sql: string, target: AiConversationBinding];
insertRedisCommand: [command: string, target: AiConversationBinding];
executeRedisCommand: [command: string, target: AiConversationBinding];
@@ -1543,6 +1544,9 @@ function submitEdit(visibleIndex: number) {
}
draftPluginContext.value = pluginContext.value;
messages.value = messages.value.slice(0, actualIndex);
// The truncated tail carried the positional SQL-block statuses of the messages
// that just disappeared; the regenerated message reuses those indices.
clearSqlActionBlockStatuses();
editingMessageIndex.value = null;
editingContent.value = "";
selectedMentions.value = editingMentions.value.filter((mention): mention is AiTableMention & { kind: "table" } => mention.kind === "table").map(({ raw, schema, table }) => ({ raw, schema, table }));
@@ -5088,6 +5092,7 @@ function resetPendingRequestState() {
// unmount must drop it synchronously, or "识别中" stays on screen in the next
// conversation until that bounded call settles.
routingInFlight.value = false;
clearSqlActionBlockStatuses();
}
// `alreadyCancelledSessionId`: the session id a caller (cancelStream()) has
@@ -5120,28 +5125,126 @@ function abandonInFlightRequest(alreadyCancelledSessionId?: string) {
}
}
function applySql(code: string) {
function applySql(code: string, key: string) {
if (isRedisConnection.value) {
emit("insertRedisCommand", code, conversationBinding.value);
return;
}
emit("appendSql", code, conversationBinding.value);
// Start the block's status before the request leaves: the target tab may not
// be on screen, so this block is the only place the user can see it happen.
const requestId = beginSqlAction(key);
emit("appendSql", code, conversationBinding.value, requestId);
}
function executeSql(code: string) {
function executeSql(code: string, key: string) {
if (isRedisConnection.value) {
emit("executeRedisCommand", code, conversationBinding.value);
return;
}
emit("executeSql", code, conversationBinding.value);
const requestId = beginSqlAction(key);
emit("executeSql", code, conversationBinding.value, requestId);
}
function tempRunSql(code: string) {
function tempRunSql(code: string, key: string) {
if (isRedisConnection.value) {
emit("executeRedisCommand", code, conversationBinding.value);
return;
}
emit("tempRunSql", code, conversationBinding.value);
const requestId = beginSqlAction(key);
emit("tempRunSql", code, conversationBinding.value, requestId);
}
/**
* Per-SQL-block action status (#11962). The panel is the only surface that can
* report an AI action's outcome when its editor tab is not on screen, so each
* block tracks the request it started and renders running/succeeded/failed
* inline.
*/
type AiSqlBlockStatus =
/** `requestId` is the click this block currently waits for (see beginSqlAction). */
| { phase: "running"; requestId: number }
| { phase: "succeeded"; rows?: number; affectedRows?: number }
/** `apply` appended the SQL to the editor: there is no execution to summarise. */
| { phase: "applied" }
| { phase: "failed"; message: string }
| { phase: "unavailable"; message: string };
const aiSqlBlockStatuses = ref<Map<string, AiSqlBlockStatus>>(new Map());
const pendingSqlActionRequests = new Map<number, string>();
let nextSqlActionRequestId = 0;
function beginSqlAction(key: string): number {
const requestId = ++nextSqlActionRequestId;
pendingSqlActionRequests.set(requestId, key);
const next = new Map(aiSqlBlockStatuses.value);
// A second click on the same block supersedes the first: the block tracks the
// request it is waiting for, so the earlier response cannot overwrite it.
next.set(key, { phase: "running", requestId });
aiSqlBlockStatuses.value = next;
return requestId;
}
/**
* Block keys are positional (`code:${messageIndex}:${segment}`), so a status only
* means anything while the transcript that produced it is still on screen.
* Every path that replaces or truncates the transcript must drop them, or a
* stale "failed" line renders on whatever block now sits at that index — in this
* chat after an edit-resend, or in another conversation entirely. The call sites
* are `selectConversation`, `clearMessages`, the edit-resend truncation, and
* `resetPendingRequestState`.
*/
function clearSqlActionBlockStatuses() {
if (aiSqlBlockStatuses.value.size) aiSqlBlockStatuses.value = new Map();
pendingSqlActionRequests.clear();
}
/** Host callback: settles the block that started request `requestId`. */
function reportAiSqlAction(report: AiSqlActionReport) {
const key = pendingSqlActionRequests.get(report.requestId);
if (key === undefined) return;
pendingSqlActionRequests.delete(report.requestId);
const pending = aiSqlBlockStatuses.value.get(key);
if (pending?.phase === "running" && pending.requestId !== report.requestId) {
// The block was clicked again while this request was in flight; that later
// click owns the block's status, so this stale answer is dropped.
return;
}
const next = new Map(aiSqlBlockStatuses.value);
if (report.status === "dismissed") {
// The user answered a dialog (danger/production/parameters/read-only) or the
// request was cancelled: there is no outcome to report, so drop the state
// instead of leaving the block spinning forever.
next.delete(key);
} else if (report.status === "succeeded") {
next.set(key, { phase: "succeeded", rows: report.rows, affectedRows: report.affectedRows });
} else if (report.status === "applied") {
next.set(key, { phase: "applied" });
} else if (report.status === "failed") {
next.set(key, { phase: "failed", message: report.message ?? "" });
} else {
next.set(key, { phase: "unavailable", message: report.message ?? "" });
}
aiSqlBlockStatuses.value = next;
}
const sqlBlockKey = (messageIndex: number, segmentIndex: number) => `code:${messageIndex}:${segmentIndex}`;
/** Status of one SQL block's action, or `undefined` when nothing ran from it. */
function sqlBlockStatus(messageIndex: number, segmentIndex: number): AiSqlBlockStatus | undefined {
return aiSqlBlockStatuses.value.get(sqlBlockKey(messageIndex, segmentIndex));
}
function sqlBlockStatusText(messageIndex: number, segmentIndex: number): string {
const status = sqlBlockStatus(messageIndex, segmentIndex);
if (!status) return "";
if (status.phase === "running") return t("ai.sqlActionRunning");
if (status.phase === "applied") return t("ai.sqlActionApplied");
if (status.phase === "succeeded") {
if (status.affectedRows) return t("ai.sqlActionSucceededAffected", { count: status.affectedRows });
if (status.rows) return t("ai.sqlActionSucceededRows", { count: status.rows });
return t("ai.sqlActionSucceeded");
}
return status.message;
}
const copiedContentKey = ref("");
@@ -5314,6 +5417,8 @@ function clearMessages() {
// the transcript, so returning to this conversation shows what changed.
if (conversationId.value) conversationReadMessageCount.set(conversationId.value, visibleMessages.value.length);
messages.value = [];
// Positional SQL-block statuses belong to the transcript just wiped.
clearSqlActionBlockStatuses();
draftPluginContext.value = undefined;
clearContextReferences();
clearPendingWriteGrant();
@@ -5667,6 +5772,9 @@ function selectConversation(conv: AiConversation) {
skillHintLatch.value = null;
const activeRun = backgroundAiRunsEnabled ? desktopAiRun<ChatMessage>(conv.id) : undefined;
messages.value = activeRun?.messages ?? chatMessagesFromConversation(conv);
// Block keys are positional, so the arriving transcript must not inherit the
// departing conversation's statuses (#11962).
clearSqlActionBlockStatuses();
// Req 13 after a restart: the transcript keeps the fact that skills were
// loaded, so the chips can light up before anything is read again. The bodies
// come back when a request actually needs them (`rehydrateLoadedSkillBodies`).
@@ -6442,7 +6550,7 @@ function focusSearch(): boolean {
return true;
}
defineExpose({ openPluginConversation, openExternalContext, triggerAction, clearContextReferences, selectConversationById, focusSearch });
defineExpose({ openPluginConversation, openExternalContext, triggerAction, clearContextReferences, selectConversationById, focusSearch, reportAiSqlAction });
const messageRenderer = computed(() => {
const appearance = aiCodeAppearance.value;
@@ -6914,7 +7022,7 @@ async function openExternalUrl(url: string) {
v-if="!pluginContext && !seg.pending && seg.isSql && !isRedisConnection"
class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
:title="t('ai.tempRunSql')"
@click="tempRunSql(seg.content)"
@click="tempRunSql(seg.content, sqlBlockKey(i, j))"
>
<FlaskConical class="h-3.5 w-3.5" />
</button>
@@ -6922,7 +7030,7 @@ async function openExternalUrl(url: string) {
v-if="!pluginContext && !seg.pending && (seg.isSql || isRedisConnection)"
class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
:title="t('ai.executeSql')"
@click="executeSql(seg.content)"
@click="executeSql(seg.content, sqlBlockKey(i, j))"
>
<Play class="h-3.5 w-3.5" />
</button>
@@ -6930,7 +7038,7 @@ async function openExternalUrl(url: string) {
v-if="!pluginContext && !seg.pending && (seg.isSql || isRedisConnection)"
class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
:title="t('ai.apply')"
@click="applySql(seg.content)"
@click="applySql(seg.content, sqlBlockKey(i, j))"
>
<ArrowDownToLine class="h-3.5 w-3.5" />
</button>
@@ -6948,6 +7056,25 @@ async function openExternalUrl(url: string) {
</div>
</div>
<pre class="ai-code-block whitespace-pre-wrap break-words [overflow-wrap:anywhere] p-3 text-xs leading-relaxed text-zinc-900 dark:text-zinc-100"><code v-html="seg.html"></code></pre>
<!-- #11962: the outcome of an action started from THIS block, so the
user sees it without hunting for the target editor tab. The row
stays mounted while the action runs, so its running → terminal
text change is announced; `role="status"` matches the other
block-level notices in this file. -->
<div
v-if="sqlBlockStatus(i, j)"
data-ai-sql-action-status
role="status"
class="border-t border-zinc-200 px-3 py-1.5 text-[10px] dark:border-zinc-700/50"
:class="sqlBlockStatus(i, j)?.phase === 'failed' || sqlBlockStatus(i, j)?.phase === 'unavailable' ? 'text-red-600 dark:text-red-400' : 'text-zinc-500 dark:text-zinc-400'"
>
<span class="flex items-start gap-1.5">
<Loader2 v-if="sqlBlockStatus(i, j)?.phase === 'running'" class="mt-px h-3 w-3 shrink-0 animate-spin" />
<Check v-else-if="sqlBlockStatus(i, j)?.phase === 'succeeded' || sqlBlockStatus(i, j)?.phase === 'applied'" class="mt-px h-3 w-3 shrink-0 text-emerald-500" />
<CircleSlash v-else class="mt-px h-3 w-3 shrink-0" />
<span class="min-w-0 whitespace-pre-wrap break-words">{{ sqlBlockStatusText(i, j) }}</span>
</span>
</div>
</div>
</template>
<div v-if="msg === proposalConfirmMessage" class="mt-2 flex gap-2" :title="t('ai.proposalConfirmTitle')">
@@ -0,0 +1,195 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Regression coverage for https://github.com/t8y2/dbx/issues/11962.
//
// The AI panel's SQL actions deliver to an editor tab resolved from the
// conversation's bound connection, created without activation (#9902). When the
// tab was not on screen the click looked like a no-op and the database error
// stayed in that background tab. App.vue is a large SFC the suite never mounts,
// so the wiring is pinned as source text (same approach as
// aiRedisConsoleRouting.spec.ts); the decision table itself is unit-tested in
// lib/ai/__tests__/aiSqlActionFeedback.spec.ts.
//
// These assertions read the .vue sources verbatim, so run `oxfmt` on the changed
// files BEFORE running this spec — a reflow of the pinned text fails it.
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
function appBodyOf(fnSignature: string): string {
const start = appSource.indexOf(fnSignature);
expect(start, `expected to find "${fnSignature}" in App.vue`).toBeGreaterThanOrEqual(0);
const braceStart = appSource.indexOf("{", start);
let depth = 0;
for (let i = braceStart; i < appSource.length; i++) {
if (appSource[i] === "{") depth++;
else if (appSource[i] === "}") {
depth--;
if (depth === 0) return appSource.slice(braceStart, i + 1);
}
}
throw new Error(`unbalanced braces reading body of "${fnSignature}"`);
}
describe("AI SQL action feedback (#11962)", () => {
it("treats a target tab as visible only when the workspace renders and some group shows it", () => {
const onScreen = appBodyOf("function aiTargetTabOnScreen(tabId: string): boolean");
expect(onScreen).toContain("workspaceRendered()");
// Same idiom as SqlEditorWorkspace.editorGroupForTab: every group renders, so
// a non-focused split group's active tab counts as visible.
expect(onScreen).toContain("queryStore.groups.some((group) => group.activeTabId === tabId)");
const rendered = appBodyOf("function workspaceRendered(): boolean");
// Mirrors the v-show on [data-editor-content]: a maximized AI panel hides the
// workspace, so nothing in it can deliver feedback.
expect(rendered).toContain("!isAiPanelMaximized.value");
expect(rendered).toContain("!isHistoryPanelMaximized.value");
expect(rendered).toContain("!isSpecialPageActive.value");
expect(rendered).not.toContain("isZenMode");
});
it("routes the feedback decision through the pure mapper and only reveals on click", () => {
const settle = appBodyOf("function settleAiSqlAction(requestId: number | undefined, outcome: AiSqlActionOutcome): void");
expect(settle).toContain("aiSqlActionFeedback(outcome, { targetOnScreen");
expect(settle).toContain('if (feedback.level === "none" || feedbackText === undefined) return;');
// The toast's action button is the ONLY activation site on this path.
expect(settle).toContain("onClick: () => activateQueryTab(revealTabId)");
expect(settle).not.toContain("activateQueryTab(tabId)");
expect(settle).toContain("aiAssistantRef.value.reportAiSqlAction(report);");
// The summary shown in the panel comes from the target tab's own result.
expect(settle).toContain("aiTargetResultCounts(outcome.tabId)");
// A request that never started still says why inside the block, where the text
// outlives the toast.
expect(settle).toContain('{ requestId, status: "unavailable" as const, ...(feedbackText ? { message: feedbackText } : {}) }');
});
it("reports dismissal instead of re-toasting a dialog the user already answered", () => {
const reasons = appSource.slice(appSource.indexOf("const DISMISSED_OUTCOME_REASONS"), appSource.indexOf("const DISMISSED_OUTCOME_REASONS") + 200);
for (const reason of ["cancelled", "parameters", "blocked", "missing-database"]) expect(reasons).toContain(`"${reason}"`);
});
it("leaves no silent early return on the three AI SQL action paths", () => {
for (const handler of ["function onAiAppendSql(", "function onAiExecuteSql("]) {
const body = appBodyOf(handler);
// Every `!tabId` branch settles with a reportable reason...
expect(body).toContain('settleAiSqlAction(requestId, { status: "not-started", reason: "no-connection" });');
expect(body).not.toMatch(/if \(!tabId\) return;/);
}
// ...including the temp-run path, which has no branch of its own and hands
// the empty tab id to the shared helper.
expect(appBodyOf("function runAiGeneratedSql(")).toContain("if (!tabId) {");
expect(appBodyOf("function runAiGeneratedSql(")).toContain('settleAiSqlAction(requestId, { status: "not-started", reason: "no-connection" });');
expect(appBodyOf("function runAiGeneratedSql(")).toContain("await tryExecute(sql, { tabId })");
expect(appBodyOf("function onAiTempRunSql(")).toContain("void runAiGeneratedSql(sql, ensureQueryTabForConnection(target), requestId);");
});
it("keeps #9902 intact: the AI action itself never moves the workspace", () => {
// Target resolution is unchanged (connection + database + exact schema).
const target = appBodyOf("function ensureQueryTabForConnection(target: AiConversationBinding): string");
expect(target).toContain("{ activate: false }");
// No AI action handler activates, selects or creates a tab behind the user's back.
for (const handler of ["function onAiAppendSql(", "function onAiExecuteSql(", "function onAiTempRunSql(", "function runAiGeneratedSql("]) {
const body = appBodyOf(handler);
expect(body).not.toContain("activateTab");
expect(body).not.toContain("switchTab");
}
});
it("passes the request id through every AI action emit", () => {
expect(appSource).toContain('@append-sql="onAiAppendSql"');
expect(appSource).toContain('@execute-sql="onAiExecuteSql"');
expect(appSource).toContain('@temp-run-sql="onAiTempRunSql"');
// Every AI action emit forwards the request id as a third argument.
const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
expect(assistant).toContain('emit("appendSql", code, conversationBinding.value, requestId);');
expect(assistant).toContain('emit("executeSql", code, conversationBinding.value, requestId);');
expect(assistant).toContain('emit("tempRunSql", code, conversationBinding.value, requestId);');
// The auto-execute path is deliberately excluded: it already reports in-chat.
expect(appSource).toContain('@request-auto-execute-sql="onAiRequestAutoExecuteSql"');
expect(appBodyOf("function onAiRequestAutoExecuteSql(")).not.toContain("settleAiSqlAction");
});
it("declares the panel report method on the handle type and the component", () => {
expect(appSource).toContain("reportAiSqlAction: (report: AiSqlActionReport) => void;");
const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
expect(assistant).toContain("function reportAiSqlAction(report: AiSqlActionReport)");
expect(assistant).toContain("defineExpose({ openPluginConversation, openExternalContext, triggerAction, clearContextReferences, selectConversationById, focusSearch, reportAiSqlAction });");
});
it("drops positional block statuses whenever the transcript is replaced", () => {
const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
// Block keys are positional (`code:<message>:<segment>`), so a status left
// behind by a wiped transcript would render as the outcome of whatever block
// now sits at that index — another conversation's error, or the regenerated
// message of an edit-resend.
expect(assistant.match(/function clearSqlActionBlockStatuses\(/g)).toHaveLength(1);
for (const replaced of [
"messages.value = messages.value.slice(0, actualIndex);", // edit-resend truncation
"messages.value = [];", // clearMessages (new chat)
"messages.value = activeRun?.messages ?? chatMessagesFromConversation(conv);", // selectConversation
]) {
const at = assistant.indexOf(replaced);
expect(at, `expected to find "${replaced}" in AiAssistant.vue`).toBeGreaterThanOrEqual(0);
expect(assistant.slice(at, at + 300)).toContain("clearSqlActionBlockStatuses();");
}
});
it("announces the block outcome through a status live region", () => {
const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
// The outcome lands in the transcript, not in the tab the user is looking at,
// so it has to be announced. `role="status"` matches the other block-level
// notices in this file; the row stays mounted between running and terminal, so
// the change is a text mutation inside an existing live region.
const row = assistant.slice(assistant.indexOf("data-ai-sql-action-status"));
expect(row.slice(0, 200)).toContain('role="status"');
});
it("lets the newest click on a block own its status", () => {
const assistant = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
// Clicking the same block twice used to let the FIRST response settle the
// block (last write wins by arrival order, not by click order).
expect(assistant).toContain('next.set(key, { phase: "running", requestId });');
expect(assistant).toContain('if (pending?.phase === "running" && pending.requestId !== report.requestId)');
});
it("carries the AI request across a dialog-parked execution", () => {
// A parameter / danger / Redis dialog makes tryExecute return `deferred`, so the
// settled execution has to be correlated back to the click: App remembers the
// request per tab and settles from the resumed execution.
const settle = appBodyOf("function settleAiSqlAction(requestId: number | undefined, outcome: AiSqlActionOutcome): void");
expect(settle).toContain('if (outcome.status === "deferred") {');
expect(settle).toContain("rememberDeferredAiSqlAction(outcome.tabId, requestId);");
const deferredSettle = appBodyOf("function settleDeferredAiSqlAction(outcome: SqlExecutionOutcome): void");
expect(deferredSettle).toContain("deferredAiSqlActions.get(tabId)");
expect(deferredSettle).toContain("if (requestId === undefined) return;");
expect(deferredSettle).toContain("settleAiSqlAction(requestId, outcome);");
// Editor-initiated executions reach the same callback and must not be toasted.
expect(deferredSettle).toContain("deferredAiSqlActions.delete(tabId);");
// The composable reports exactly where the dialog's own handlers discarded the
// outcome before (#11962 review).
const execution = readFileSync(new URL("../../../composables/useSqlExecution.ts", import.meta.url), "utf8");
expect(execution).toContain("reportDeferredExecution(await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: tab.id }));");
expect(execution).toContain("reportDeferredExecution(await continueExecute(context, sql, sourceOffset,");
expect(execution).toContain('return deferred("parameters", context.tabId);');
expect(execution).toContain('return deferred("danger", context.tabId);');
expect(execution).toContain('return deferred("redis-confirm", context.tabId);');
// Cancelling a dialog unbinds the parked request instead of leaving it waiting.
expect(execution).toContain('if (abandonedTabId) reportDeferredExecution(notStarted("cancelled", abandonedTabId));');
});
it("names the target tab in the off-screen toast of every locale", () => {
// The toast is the only thing that tells the user *where* an off-screen run
// landed, so these keys must interpolate the tab title — and the block copy
// must not (it renders under the SQL, with no tab name to interpolate).
const locales = ["az", "en", "es", "id", "it", "ja", "ko", "pt-BR", "ru", "th", "tr", "zh-CN", "zh-TW"];
for (const locale of locales) {
const messages = readFileSync(new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url), "utf8");
expect(messages, locale).toMatch(/sqlActionSucceededIn: "[^"]*\{title\}[^"]*"/);
expect(messages, locale).toMatch(/sqlActionAppliedIn: "[^"]*\{title\}[^"]*"/);
expect(messages, locale).toMatch(/sqlActionFailed: "[^"]*\{title\}[^"]*\{message\}[^"]*"/);
expect(messages, locale).toMatch(/sqlActionSucceeded: "[^"{]*"/);
}
});
});
@@ -928,6 +928,28 @@ SELECT @value AS Message;`;
expect(executeCurrentSql).toHaveBeenCalledWith(resolvedSql, { tabId: "tab-1", openInNewResultTab: true });
});
it("settles a failed execution after accepting a SQL parameter dialog", async () => {
const sql = "SELECT * FROM users WHERE id = :id";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
vi.spyOn(useQueryStore(), "executeCurrentSql").mockRejectedValue(new Error("connection lost"));
const onDeferredExecutionSettled = vi.fn();
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
onDeferredExecutionSettled,
});
await execution.tryExecute();
await execution.onSqlParametersConfirm("SELECT * FROM users WHERE id = 7");
expect(onDeferredExecutionSettled).toHaveBeenCalledWith({ status: "started", ok: false, errorMessage: "connection lost", tabId: "tab-1" });
});
it("executes the PostgreSQL ARRAY date-format query without opening the parameter dialog", async () => {
const sql = `
WITH rec_flow AS (
@@ -1592,6 +1614,28 @@ SELECT @value AS Message;`;
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", openInNewResultTab: true });
});
it("settles a failed execution after accepting a dangerous-SQL dialog", async () => {
const sql = "DELETE FROM users WHERE id = 7";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
vi.spyOn(useQueryStore(), "executeCurrentSql").mockRejectedValue(new Error("connection lost"));
const onDeferredExecutionSettled = vi.fn();
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
onDeferredExecutionSettled,
});
await execution.tryExecute();
await execution.onDangerConfirm();
expect(onDeferredExecutionSettled).toHaveBeenCalledWith({ status: "started", ok: false, errorMessage: "connection lost", tabId: "tab-1" });
});
it("keeps the active retained result when a new-result dangerous prompt is cancelled", async () => {
const result = { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
const activeTab = ref<QueryTab | undefined>({
+99 -26
View File
@@ -35,6 +35,7 @@ import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry";
import { MULTI_SOURCE_MAX_ROWS_PER_SOURCE } from "@/lib/query/multiSourceResult";
import { translateBackendError } from "@/i18n/backend-errors";
import type { SqlExecutionDeferredReason, SqlExecutionNotStartedReason, SqlExecutionOutcome } from "@/lib/sql/sqlExecutionOutcome";
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
@@ -170,6 +171,13 @@ export function useSqlExecution(deps: {
requestDangerConfirmation?: (request: SqlExecutionDangerRequest) => Promise<boolean>;
onExecutionStarted?: (editorViewportRequestId: number) => void;
onExecutionCancelled?: (editorViewportRequestId: number) => void;
/**
* Terminal outcome of an execution a dialog parked (#11962). Such a request
* leaves `tryExecute` as `{ status: "deferred" }` when the dialog opens, so this
* is the only place its real result arrives: accepted → the execution outcome,
* dismissed → `not-started`. Hosts that do not track the request ignore it.
*/
onDeferredExecutionSettled?: (outcome: SqlExecutionOutcome) => void;
}) {
const { t } = useI18n();
const queryStore = useQueryStore();
@@ -245,47 +253,78 @@ export function useSqlExecution(deps: {
return { sql, sourceOffset: source.selectionFrom + leadingWhitespace, editorViewportRequestId };
}
async function tryExecute(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) {
/**
* Every early return reports *why* it stopped (#11962). The editor path
* ignores this value, but the AI panel needs it: the target tab may not be on
* screen, so a silent return there leaves the user with no feedback at all.
*/
function notStarted(reason: SqlExecutionNotStartedReason, tabId?: string): SqlExecutionOutcome {
return { status: "not-started", reason, ...(tabId ? { tabId } : {}) };
}
function deferred(reason: SqlExecutionDeferredReason, tabId: string): SqlExecutionOutcome {
return { status: "deferred", reason, tabId };
}
/**
* Report for the execution a dialog parked (#11962). `tryExecute` returned
* `deferred` when it opened the dialog, so the caller that needs the real
* outcome — the AI panel — is told here instead: once the user answers, and with
* whatever the resumed execution produced. Editor-initiated executions reach this
* too; they simply have no listener registered for them.
*/
function reportDeferredExecution(outcome: SqlExecutionOutcome) {
deps.onDeferredExecutionSettled?.(outcome);
}
/** A resumed confirmation must settle its AI request even when execution throws. */
function reportDeferredExecutionFailure(error: unknown, tabId: string) {
reportDeferredExecution({ status: "started", ok: false, errorMessage: translateBackendError(t, error), tabId });
}
async function tryExecute(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}): Promise<SqlExecutionOutcome> {
const context = captureExecutionContext(options.tabId);
if (!context) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
return notStarted("no-tab", options.tabId);
}
const { sql, sourceOffset, editorViewportRequestId } = await resolvedExecutableSql(sqlOverride, context);
const executionOptions = { ...options, editorViewportRequestId, tabId: context.tabId };
if (!sql.trim()) {
cancelEditorViewportRequest(editorViewportRequestId);
return;
return notStarted("empty-sql", context.tabId);
}
const tab = resolveExecutionTab(context.tabId);
if (!tab) {
cancelEditorViewportRequest(editorViewportRequestId);
return;
return notStarted("no-tab", context.tabId);
}
if (requiresDatabaseSelection(tab, context.connection, sql)) {
deps.onMissingDatabase?.(context.tabId);
cancelEditorViewportRequest(editorViewportRequestId);
return;
return notStarted("missing-database", context.tabId);
}
if (supportsSqlTemplateParameters(context.connection, sql) && prepareSqlParameterDialog(sql, sourceOffset, executionOptions)) {
return;
return deferred("parameters", context.tabId);
}
await continueExecute(context, sql, sourceOffset, executionOptions);
return continueExecute(context, sql, sourceOffset, executionOptions);
}
function tryExecuteInNewResultTab(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) {
return tryExecute(sqlOverride, { ...options, openInNewResultTab: true });
}
async function continueExecute(context: SqlExecutionContext, sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
async function continueExecute(context: SqlExecutionContext, sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}): Promise<SqlExecutionOutcome> {
const tab = resolveExecutionTab(context.tabId);
if (!tab) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
return notStarted("no-tab", context.tabId);
}
if (!(await ensureReadOnlyWriteAccess({ connection: context.connection, sql, source: t("production.sourceSqlEditor") }))) {
// The read-only unlock dialog was dismissed: the user saw UI, so the panel
// adds nothing (see aiSqlActionFeedback).
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
return notStarted("cancelled", context.tabId);
}
// Redis: block dangerous commands when toggle is on (scan entire batch for highest safety level)
if (context.connection?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) {
@@ -307,7 +346,7 @@ export function useSqlExecution(deps: {
if (highestSafety === "blocked") {
toast(t("redis.blockedCommand", { command: "Redis" }), 5000);
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
return notStarted("blocked", context.tabId);
}
if (highestSafety === "confirm") {
dangerSql.value = sql;
@@ -319,7 +358,9 @@ export function useSqlExecution(deps: {
pendingDangerTabId.value = context.tabId;
suppressDangerConfirm.value = false;
showDangerDialog.value = true;
return;
// Parked on the Redis confirmation, not finished: `onDangerConfirm` reports
// the real outcome.
return deferred("redis-confirm", context.tabId);
}
}
const productionAssessment = assessProductionSql(sql, context.connection, tab.database);
@@ -333,11 +374,10 @@ export function useSqlExecution(deps: {
source: t("production.sourceSqlEditor"),
});
if (confirmed) {
await doExecute(sql, sourceOffset, options);
} else {
cancelEditorViewportRequest(options.editorViewportRequestId);
return doExecute(sql, sourceOffset, options);
}
return;
cancelEditorViewportRequest(options.editorViewportRequestId);
return notStarted("cancelled", context.tabId);
}
if (isDangerousSql(sql, context.connection?.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
dangerSql.value = sql;
@@ -349,9 +389,10 @@ export function useSqlExecution(deps: {
pendingDangerTabId.value = context.tabId;
suppressDangerConfirm.value = false;
showDangerDialog.value = true;
} else {
await doExecute(sql, sourceOffset, options);
// Parked on the danger confirmation; `onDangerConfirm` reports the outcome.
return deferred("danger", context.tabId);
}
return doExecute(sql, sourceOffset, options);
}
function prepareSqlParameterDialog(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}, continuation?: (sql: string, sourceOffset?: number) => Promise<void> | void): boolean {
@@ -406,31 +447,31 @@ export function useSqlExecution(deps: {
queryStore.setActiveResultIndex(executionTabId, sqlServerDataResultIndex !== undefined && sqlServerDataResultIndex >= 0 ? sqlServerDataResultIndex : sqlServerMessageResultIndex);
}
async function doExecute(sql?: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
async function doExecute(sql?: string, sourceOffset?: number, options: SqlExecutionOptions = {}): Promise<SqlExecutionOutcome> {
if (sql === undefined) {
const context = captureExecutionContext(options.tabId);
if (!context) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
return notStarted("no-tab", options.tabId);
}
({ sql, sourceOffset } = await resolvedExecutableSql(undefined, context));
}
const executionTabId = options.tabId ?? deps.activeTab.value?.id;
if (!executionTabId || !sql || !sql.trim()) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
return notStarted("empty-sql", executionTabId);
}
const tab = resolveExecutionTab(executionTabId);
if (!tab) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
return notStarted("no-tab", executionTabId);
}
const executionConnection = connectionStore.getConfig(tab.connectionId) ?? deps.activeConnection.value;
const executionDatabaseType = executionConnection?.db_type;
if (requiresDatabaseSelection(tab, executionConnection, sql)) {
deps.onMissingDatabase?.(executionTabId);
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
return notStarted("missing-database", executionTabId);
}
const statementCount = splitSqlStatementRanges(sql, executionDatabaseType, sqlStatementParameterOptionsForCompatibility(executionDatabaseType, executionDatabaseType === "opengauss" ? connectionStore.databaseCompatibilityMode(tab.connectionId, tab.database) : undefined)).length;
const redisConsoleSelected = executionDatabaseType === "redis" && tab.uiState?.redisResultViewMode === "console";
@@ -450,8 +491,11 @@ export function useSqlExecution(deps: {
...(options.editorViewportRequestId !== undefined ? { onExecutionStarted: () => deps.onExecutionStarted?.(options.editorViewportRequestId!) } : {}),
});
if (producedResult === false) {
// `executeCurrentSql` reports `false` while another request is already
// preparing a result run for this tab (queryStore's pending-preparation
// guard), i.e. the tab is busy — never a silent no-op.
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
return notStarted("busy", executionTabId);
}
const executionTabStillActive = deps.activeTab.value?.id === executionTabId;
const sqlServerMessageResultIndex = executionDatabaseType === "sqlserver" ? tab.results?.findIndex((result) => result.server_message === true) : undefined;
@@ -502,6 +546,14 @@ export function useSqlExecution(deps: {
await connectionStore.refreshObjectListTreeNode(tab.connectionId, tab.database, refreshTarget.schema);
}
}
return {
status: "started",
ok: success,
tabId: tab.id,
// Same text the history entry records, so the panel shows the database's
// own words rather than a second, drifting translation of them.
...(failure ? { errorMessage: failure.error ? translateBackendError(t, failure.error, failure.rows?.[0]?.[0]) : String(failure.rows?.[0]?.[0] ?? "") } : {}),
};
}
async function executeTargetSql(input: TargetSqlExecutionInput): Promise<MultiDbTargetExecutionResult> {
@@ -905,14 +957,20 @@ export function useSqlExecution(deps: {
suppressDangerConfirm.value = false;
const tab = resolveExecutionTab(tabId);
if (!tab) {
reportDeferredExecution(notStarted("no-tab", tabId));
return;
}
const connection = connectionStore.getConfig(tab.connectionId);
if (!(await ensureReadOnlyWriteAccess({ connection, sql, source: t("production.sourceSqlEditor") }))) {
cancelEditorViewportRequest(editorViewportRequestId);
reportDeferredExecution(notStarted("cancelled", tab.id));
return;
}
await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: tab.id });
try {
reportDeferredExecution(await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: tab.id }));
} catch (error) {
reportDeferredExecutionFailure(error, tab.id);
}
}
async function onSqlParametersConfirm(sql: string) {
@@ -937,14 +995,24 @@ export function useSqlExecution(deps: {
}
const context = captureExecutionContext(tabId);
if (!context) {
reportDeferredExecution(notStarted("no-tab", tabId));
return;
}
await continueExecute(context, sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: context.tabId });
try {
reportDeferredExecution(await continueExecute(context, sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: context.tabId }));
} catch (error) {
reportDeferredExecutionFailure(error, context.tabId);
}
}
watch(showSqlParameterDialog, (open) => {
if (open) return;
const editorViewportRequestId = pendingSqlParameterEditorViewportRequestId.value;
// Still set means the dialog was dismissed rather than accepted:
// `onSqlParametersConfirm` clears it up front, before it resumes the execution
// and reports that outcome itself. Without this the caller parked on the dialog
// would wait forever (#11962).
const abandonedTabId = pendingSqlParameterTabId.value;
sqlParameterSourceSql.value = "";
sqlParameterNames.value = [];
sqlParameterDatabaseType.value = undefined;
@@ -955,11 +1023,15 @@ export function useSqlExecution(deps: {
pendingSqlParameterTabId.value = undefined;
pendingSqlParameterContinuation = undefined;
cancelEditorViewportRequest(editorViewportRequestId);
if (abandonedTabId) reportDeferredExecution(notStarted("cancelled", abandonedTabId));
});
watch(showDangerDialog, (open) => {
if (open) return;
const editorViewportRequestId = pendingDangerEditorViewportRequestId.value;
// Same dismissal contract as the parameter dialog: a tab id still pending here
// means the user closed the dialog instead of confirming it.
const abandonedTabId = pendingDangerTabId.value;
pendingDangerSql.value = "";
pendingDangerSourceOffset.value = undefined;
pendingDangerKind.value = "sql";
@@ -968,6 +1040,7 @@ export function useSqlExecution(deps: {
pendingDangerTabId.value = undefined;
suppressDangerConfirm.value = false;
cancelEditorViewportRequest(editorViewportRequestId);
if (abandonedTabId) reportDeferredExecution(notStarted("cancelled", abandonedTabId));
});
return {
@@ -49,6 +49,11 @@ const FEATURE_MESSAGES: FeatureMessages[] = [
{ feature: "AI conversation export (#6467)", keys: under("ai", ["exportConversation", "conversationExportMarkdown", "conversationExportHtml", "conversationRoleUser", "conversationRoleAssistant", "conversationFailedMarker", "conversationExportEmpty"]) },
{ feature: "AI conversation management", keys: under("ai", ["renameConversation", "conversationRenameFailed", "clearDatabaseSelection", "searchDatabases", "noDatabasesFound"]) },
{ feature: "AI auto routing (#9118)", keys: ["ai.actions.auto", "ai.routing.recognizing", "ai.routing.chip", "ai.routing.switchTo", "ai.defaultAutoRouting", "ai.defaultAutoRoutingDescription"] },
{
feature: "AI SQL action feedback (#11962)",
keys: under("ai", ["sqlActionRunning", "sqlActionSucceeded", "sqlActionSucceededIn", "sqlActionSucceededRows", "sqlActionSucceededAffected", "sqlActionFailed", "sqlActionApplied", "sqlActionAppliedIn", "sqlActionEmptySql", "sqlActionBusy", "sqlActionNoConnection", "sqlActionViewTab"]),
translated: true,
},
{ feature: "plugin AI conversation", keys: ["ai.pluginHttpModelOnly", "ai.pluginWelcome", "ai.pluginFollowUp"] },
{ feature: "quick open", keys: ["quickOpen.*", "settings.shortcutGlobalSearch"] },
{
+12
View File
@@ -3171,6 +3171,18 @@ export default withEnglishFallback({
fixWithAi: "AI ilə düzəlt",
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.",
sqlActionRunning: "İcra olunur…",
sqlActionSucceeded: "İcra tamamlandı",
sqlActionSucceededIn: "«{title}» üzrə icra tamamlandı",
sqlActionSucceededRows: "{count} sətir qaytarıldı",
sqlActionSucceededAffected: "{count} sətir təsirləndi",
sqlActionFailed: "«{title}» üzərində icra alınmadı: {message}",
sqlActionApplied: "Redaktora tətbiq edildi",
sqlActionAppliedIn: "«{title}» redaktoruna tətbiq edildi",
sqlActionEmptySql: "İcra ediləcək SQL yoxdur.",
sqlActionBusy: "Bu redaktor tabı hələ əvvəlki sorğunu emal edir. Bir az sonra yenidən cəhd edin.",
sqlActionNoConnection: "Bu söhbətin istifadə edilə bilən bağlantısı yoxdur (silinmiş ola bilər). Bağlantı seçib SQL-i yenidən icra edin.",
sqlActionViewTab: "Bax",
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ə",
+12
View File
@@ -3933,6 +3933,18 @@ export default {
fixWithAi: "Fix with AI",
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.",
sqlActionRunning: "Executing…",
sqlActionSucceeded: "Execution finished",
sqlActionSucceededIn: "Finished in “{title}”",
sqlActionSucceededRows: "{count} rows returned",
sqlActionSucceededAffected: "{count} rows affected",
sqlActionFailed: "Execution failed in “{title}”: {message}",
sqlActionApplied: "Applied to editor",
sqlActionAppliedIn: "Applied to editor “{title}”",
sqlActionEmptySql: "There is no SQL to execute.",
sqlActionBusy: "This editor tab is still processing the previous request. Try again in a moment.",
sqlActionNoConnection: "This conversation has no available connection (it may have been deleted). Pick a connection, then run the SQL again.",
sqlActionViewTab: "View",
writeSqlConfirmationRequired: "This SQL changes data or schema. Review it carefully before execution.",
writeSqlConfirmationQuestion: "Execute this SQL once?",
writeSqlConfirmYes: "Confirm execution",
+12
View File
@@ -3775,6 +3775,18 @@ export default withEnglishFallback({
fixWithAi: "Corregir con IA",
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.",
sqlActionRunning: "Ejecutando…",
sqlActionSucceeded: "Ejecución completada",
sqlActionSucceededIn: "Ejecución completada en «{title}»",
sqlActionSucceededRows: "{count} filas devueltas",
sqlActionSucceededAffected: "{count} filas afectadas",
sqlActionFailed: "La ejecución falló en «{title}»: {message}",
sqlActionApplied: "Aplicado al editor",
sqlActionAppliedIn: "Aplicado al editor «{title}»",
sqlActionEmptySql: "No hay SQL para ejecutar.",
sqlActionBusy: "Esta pestaña del editor todavía está procesando la solicitud anterior. Inténtalo de nuevo en un momento.",
sqlActionNoConnection: "Esta conversación no tiene una conexión disponible (puede que se haya eliminado). Elige una conexión y vuelve a ejecutar el SQL.",
sqlActionViewTab: "Ver",
writeSqlConfirmationRequired: "Este SQL modifica datos o el esquema. Revísalo cuidadosamente antes de ejecutarlo.",
writeSqlConfirmationQuestion: "¿Ejecutar este SQL una vez?",
writeSqlConfirmYes: "Confirmar ejecución",
+12
View File
@@ -3496,6 +3496,18 @@ export default withEnglishFallback({
fixWithAi: "Perbaiki dengan AI",
autoSqlBlocked: "SQL yang dihasilkan AI terlihat terlalu berisiko untuk dieksekusi otomatis. Tinjau secara manual sebelum menjalankannya.",
redisConsoleUnreachable: "Perintah AI tidak dapat menjangkau konsol Redis percakapan ini. Buka konsol Redis koneksi yang terhubung dan coba lagi.",
sqlActionRunning: "Menjalankan…",
sqlActionSucceeded: "Eksekusi selesai",
sqlActionSucceededIn: "Selesai dijalankan di “{title}”",
sqlActionSucceededRows: "{count} baris dikembalikan",
sqlActionSucceededAffected: "{count} baris terpengaruh",
sqlActionFailed: "Eksekusi gagal di “{title}”: {message}",
sqlActionApplied: "Diterapkan ke editor",
sqlActionAppliedIn: "Diterapkan ke editor “{title}”",
sqlActionEmptySql: "Tidak ada SQL untuk dijalankan.",
sqlActionBusy: "Tab editor ini masih memproses permintaan sebelumnya. Coba lagi sebentar lagi.",
sqlActionNoConnection: "Percakapan ini tidak memiliki koneksi yang tersedia (mungkin sudah dihapus). Pilih koneksi, lalu jalankan SQL lagi.",
sqlActionViewTab: "Lihat",
writeSqlConfirmationRequired: "SQL ini mengubah data atau skema. Tinjau dengan hati-hati sebelum eksekusi.",
writeSqlConfirmationQuestion: "Eksekusi SQL ini sekali?",
writeSqlConfirmYes: "Konfirmasi eksekusi",
+12
View File
@@ -3538,6 +3538,18 @@ export default withEnglishFallback({
fixWithAi: "Correggi con AI",
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.",
sqlActionRunning: "Esecuzione in corso…",
sqlActionSucceeded: "Esecuzione completata",
sqlActionSucceededIn: "Esecuzione completata in «{title}»",
sqlActionSucceededRows: "{count} righe restituite",
sqlActionSucceededAffected: "{count} righe modificate",
sqlActionFailed: "Esecuzione non riuscita in «{title}»: {message}",
sqlActionApplied: "Applicato all'editor",
sqlActionAppliedIn: "Applicato all'editor «{title}»",
sqlActionEmptySql: "Non c'è SQL da eseguire.",
sqlActionBusy: "Questa scheda dell'editor sta ancora elaborando la richiesta precedente. Riprova tra poco.",
sqlActionNoConnection: "Questa conversazione non ha una connessione disponibile (potrebbe essere stata eliminata). Scegli una connessione e riesegui l'SQL.",
sqlActionViewTab: "Visualizza",
writeSqlConfirmationRequired: "Questo SQL modifica dati o schema. Rivedilo attentamente prima dell'esecuzione.",
writeSqlConfirmationQuestion: "Eseguire questo SQL una volta?",
writeSqlConfirmYes: "Conferma esecuzione",
+12
View File
@@ -3654,6 +3654,18 @@ export default withEnglishFallback({
fixWithAi: "AIで修正",
autoSqlBlocked: "AIが生成したSQLは自動実行するにはリスクが高すぎると判断されました。実行前に手動で確認してください。",
redisConsoleUnreachable: "AI コマンドをこの会話に紐づく Redis コンソールに送信できませんでした。その接続の Redis コンソールを開いて再試行してください。",
sqlActionRunning: "実行中…",
sqlActionSucceeded: "実行が完了しました",
sqlActionSucceededIn: "「{title}」で実行が完了しました",
sqlActionSucceededRows: "{count} 行を返しました",
sqlActionSucceededAffected: "{count} 行に影響しました",
sqlActionFailed: "「{title}」で実行に失敗しました: {message}",
sqlActionApplied: "エディターに適用しました",
sqlActionAppliedIn: "エディター「{title}」に適用しました",
sqlActionEmptySql: "実行する SQL がありません。",
sqlActionBusy: "このエディタータブは前のリクエストを処理中です。しばらくしてから再試行してください。",
sqlActionNoConnection: "この会話に利用可能な接続がありません(削除された可能性があります)。接続を選択して再実行してください。",
sqlActionViewTab: "表示",
writeSqlConfirmationRequired: "この SQL はデータまたはスキーマを変更します。実行前に内容をよく確認してください。",
writeSqlConfirmationQuestion: "この SQL を一度実行しますか?",
writeSqlConfirmYes: "実行を確認",
+12
View File
@@ -3486,6 +3486,18 @@ export default withEnglishFallback({
fixWithAi: "AI로 수정",
autoSqlBlocked: "AI가 생성한 SQL이 자동 실행하기에는 너무 위험해 보입니다. 실행 전에 수동으로 검토하세요.",
redisConsoleUnreachable: "AI 명령을 이 대화에 바인딩된 Redis 콘솔로 전달하지 못했습니다. 해당 연결의 Redis 콘솔을 열고 다시 시도하세요.",
sqlActionRunning: "실행 중…",
sqlActionSucceeded: "실행 완료",
sqlActionSucceededIn: "“{title}”에서 실행이 완료되었습니다",
sqlActionSucceededRows: "{count}개 행 반환",
sqlActionSucceededAffected: "{count}개 행 영향",
sqlActionFailed: "“{title}”에서 실행 실패: {message}",
sqlActionApplied: "편집기에 적용했습니다",
sqlActionAppliedIn: "편집기 “{title}”에 적용했습니다",
sqlActionEmptySql: "실행할 SQL이 없습니다.",
sqlActionBusy: "이 편집기 탭이 아직 이전 요청을 처리 중입니다. 잠시 후 다시 시도하세요.",
sqlActionNoConnection: "이 대화에 사용할 수 있는 연결이 없습니다(삭제되었을 수 있음). 연결을 선택한 뒤 다시 실행하세요.",
sqlActionViewTab: "보기",
writeSqlConfirmationRequired: "이 SQL은 데이터 또는 스키마를 변경합니다. 실행 전에 신중하게 검토하세요.",
writeSqlConfirmationQuestion: "이 SQL을 한 번 실행할까요?",
writeSqlConfirmYes: "실행 확인",
+12
View File
@@ -3621,6 +3621,18 @@ export default withEnglishFallback({
fixWithAi: "Corrigir com AI",
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.",
sqlActionRunning: "Executando…",
sqlActionSucceeded: "Execução concluída",
sqlActionSucceededIn: "Execução concluída em “{title}”",
sqlActionSucceededRows: "{count} linhas retornadas",
sqlActionSucceededAffected: "{count} linhas afetadas",
sqlActionFailed: "Falha na execução em “{title}”: {message}",
sqlActionApplied: "Aplicado ao editor",
sqlActionAppliedIn: "Aplicado ao editor “{title}”",
sqlActionEmptySql: "Não há SQL para executar.",
sqlActionBusy: "Esta aba do editor ainda está processando a solicitação anterior. Tente novamente em instantes.",
sqlActionNoConnection: "Esta conversa não tem uma conexão disponível (ela pode ter sido excluída). Escolha uma conexão e execute o SQL novamente.",
sqlActionViewTab: "Ver",
writeSqlConfirmationRequired: "Este SQL altera dados ou o esquema. Revise-o cuidadosamente antes de executar.",
writeSqlConfirmationQuestion: "Executar este SQL uma vez?",
writeSqlConfirmYes: "Confirmar execução",
+12
View File
@@ -3645,6 +3645,18 @@ export default withEnglishFallback({
fixWithAi: "Исправить с помощью ИИ",
autoSqlBlocked: "SQL, сгенерированный ИИ, выглядит слишком рискованным для автоматического выполнения. Проверьте его вручную перед запуском.",
redisConsoleUnreachable: "Команда ИИ не смогла подключиться к консоли Redis этого диалога. Откройте консоль Redis привязанного подключения и повторите попытку.",
sqlActionRunning: "Выполняется…",
sqlActionSucceeded: "Выполнение завершено",
sqlActionSucceededIn: "Выполнено в «{title}»",
sqlActionSucceededRows: "Возвращено строк: {count}",
sqlActionSucceededAffected: "Затронуто строк: {count}",
sqlActionFailed: "Ошибка выполнения в «{title}»: {message}",
sqlActionApplied: "Применено в редакторе",
sqlActionAppliedIn: "Применено в редакторе «{title}»",
sqlActionEmptySql: "Нет SQL для выполнения.",
sqlActionBusy: "Эта вкладка редактора всё ещё обрабатывает предыдущий запрос. Повторите попытку позже.",
sqlActionNoConnection: "В этом диалоге нет доступного подключения (возможно, оно удалено). Выберите подключение и выполните SQL снова.",
sqlActionViewTab: "Просмотр",
writeSqlConfirmationRequired: "Этот SQL изменяет данные или схему. Внимательно проверьте его перед выполнением.",
writeSqlConfirmationQuestion: "Выполнить этот SQL один раз?",
writeSqlConfirmYes: "Подтвердить выполнение",
+12
View File
@@ -3855,6 +3855,18 @@ export default withEnglishFallback({
fixWithAi: "แก้ไขด้วย AI",
autoSqlBlocked: "SQL ที่ AI สร้างดูเสี่ยงเกินกว่าจะเรียกใช้อัตโนมัติ ตรวจสอบด้วยตนเองก่อนเรียกใช้",
redisConsoleUnreachable: "คำสั่ง AI เข้าถึง Redis Console ของการสนทนานี้ไม่ได้ เปิด Redis Console ของการเชื่อมต่อที่ผูกไว้แล้วลองอีกครั้ง",
sqlActionRunning: "กำลังเรียกใช้…",
sqlActionSucceeded: "เรียกใช้เสร็จแล้ว",
sqlActionSucceededIn: "เรียกใช้เสร็จสิ้นใน “{title}”",
sqlActionSucceededRows: "คืนค่า {count} แถว",
sqlActionSucceededAffected: "มีผลกับ {count} แถว",
sqlActionFailed: "เรียกใช้ใน “{title}” ไม่สำเร็จ: {message}",
sqlActionApplied: "นำไปใช้กับตัวแก้ไขแล้ว",
sqlActionAppliedIn: "นำไปใช้กับตัวแก้ไข “{title}” แล้ว",
sqlActionEmptySql: "ไม่มี SQL ให้เรียกใช้",
sqlActionBusy: "แท็บตัวแก้ไขนี้ยังประมวลผลคำขอก่อนหน้าอยู่ ลองอีกครั้งในอีกสักครู่",
sqlActionNoConnection: "การสนทนานี้ไม่มีการเชื่อมต่อที่ใช้งานได้ (อาจถูกลบไปแล้ว) เลือกการเชื่อมต่อแล้วเรียกใช้ SQL อีกครั้ง",
sqlActionViewTab: "ดู",
writeSqlConfirmationRequired: "SQL นี้เปลี่ยนข้อมูลหรือ Schema ตรวจสอบอย่างระมัดระวังก่อนเรียกใช้",
writeSqlConfirmationQuestion: "เรียกใช้ SQL นี้ครั้งเดียวหรือไม่?",
writeSqlConfirmYes: "ยืนยันการเรียกใช้",
+12
View File
@@ -3145,6 +3145,18 @@ export default withEnglishFallback({
fixWithAi: "Yapay Zekâ ile Düzelt",
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.",
sqlActionRunning: "Çalıştırılıyor…",
sqlActionSucceeded: "Çalıştırma tamamlandı",
sqlActionSucceededIn: "«{title}» içinde yürütme tamamlandı",
sqlActionSucceededRows: "{count} satır döndü",
sqlActionSucceededAffected: "{count} satır etkilendi",
sqlActionFailed: "«{title}» içinde çalıştırma başarısız: {message}",
sqlActionApplied: "Düzenleyiciye uygulandı",
sqlActionAppliedIn: "«{title}» düzenleyicisine uygulandı",
sqlActionEmptySql: "Çalıştırılacak SQL yok.",
sqlActionBusy: "Bu düzenleyici sekmesi hâlâ önceki isteği işliyor. Birazdan yeniden deneyin.",
sqlActionNoConnection: "Bu konuşmanın kullanılabilir bir bağlantısı yok (silinmiş olabilir). Bir bağlantı seçip SQL'i yeniden çalıştırın.",
sqlActionViewTab: "Görüntüle",
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",
+12
View File
@@ -3863,6 +3863,18 @@ export default withEnglishFallback({
fixWithAi: "用 AI 修复",
autoSqlBlocked: "AI 生成的 SQL 风险较高,已阻止自动执行,请手动检查后再运行。",
redisConsoleUnreachable: "AI 命令未能送达本会话绑定的 Redis 控制台。请打开该连接的 Redis 控制台后重试。",
sqlActionRunning: "执行中…",
sqlActionSucceeded: "执行完成",
sqlActionSucceededIn: "已在「{title}」执行完成",
sqlActionSucceededRows: "返回 {count} 行",
sqlActionSucceededAffected: "影响 {count} 行",
sqlActionFailed: "在「{title}」执行失败:{message}",
sqlActionApplied: "已应用到编辑器",
sqlActionAppliedIn: "已应用到编辑器「{title}」",
sqlActionEmptySql: "没有可执行的 SQL。",
sqlActionBusy: "该编辑器页签仍在处理上一个请求,请稍后重试。",
sqlActionNoConnection: "本会话没有可用连接(可能已被删除)。请选择连接后重新执行。",
sqlActionViewTab: "查看",
writeSqlConfirmationRequired: "此 SQL 会修改数据或结构。执行前请仔细核对。",
writeSqlConfirmationQuestion: "确认执行此 SQL 一次吗?",
writeSqlConfirmYes: "确认执行",
+12
View File
@@ -3539,6 +3539,18 @@ export default withEnglishFallback({
fixWithAi: "用 AI 修復",
autoSqlBlocked: "AI 產生的 SQL 風險較高,已阻止自動執行,請手動檢查後再執行。",
redisConsoleUnreachable: "AI 指令未能送達本工作階段綁定的 Redis 控制台。請開啟該連線的 Redis 控制台後重試。",
sqlActionRunning: "執行中…",
sqlActionSucceeded: "執行完成",
sqlActionSucceededIn: "已在「{title}」執行完成",
sqlActionSucceededRows: "傳回 {count} 列",
sqlActionSucceededAffected: "影響 {count} 列",
sqlActionFailed: "在「{title}」執行失敗:{message}",
sqlActionApplied: "已套用到編輯器",
sqlActionAppliedIn: "已套用到編輯器「{title}」",
sqlActionEmptySql: "沒有可執行的 SQL。",
sqlActionBusy: "此編輯器分頁仍在處理上一個請求,請稍後重試。",
sqlActionNoConnection: "此工作階段沒有可用連線(可能已被刪除)。請選擇連線後重新執行。",
sqlActionViewTab: "檢視",
writeSqlConfirmationRequired: "此 SQL 會修改資料或結構。執行前請仔細核對。",
writeSqlConfirmationQuestion: "確認執行此 SQL 一次嗎?",
writeSqlConfirmYes: "確認執行",
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { aiSqlActionFeedback, type AiSqlActionOutcome } from "../aiSqlActionFeedback";
// Regression coverage for https://github.com/t8y2/dbx/issues/11962.
//
// An AI SQL action is delivered to an editor tab resolved from the
// conversation's bound connection, and that tab is deliberately created without
// activation (#9902). When it is not the active tab of any editor group, the
// click used to change nothing on screen and the database error landed only in
// that background tab. This decision table is what the panel says instead.
describe("aiSqlActionFeedback (#11962)", () => {
const started = (ok: boolean, errorMessage?: string): AiSqlActionOutcome => ({ status: "started", ok, tabId: "tab-1", ...(errorMessage ? { errorMessage } : {}) });
it("announces a success with the target tab's title and a reveal action when the tab is off screen", () => {
// The `*In` key, not the block's `sqlActionSucceeded`: the user is looking at
// the panel, so the toast has to name the tab the result landed in.
expect(aiSqlActionFeedback(started(true), { targetOnScreen: false, tabTitle: "MySQL@app" })).toEqual({
level: "info",
messageKey: "ai.sqlActionSucceededIn",
params: { title: "MySQL@app" },
revealTabId: "tab-1",
});
});
it("reports the database error text when the tab is off screen", () => {
const feedback = aiSqlActionFeedback(started(false, "Unknown column 'u.foo' in 'field list'"), { targetOnScreen: false, tabTitle: "MySQL@app" });
expect(feedback.level).toBe("error");
expect(feedback).toMatchObject({ messageKey: "ai.sqlActionFailed", params: { title: "MySQL@app", message: "Unknown column 'u.foo' in 'field list'" }, revealTabId: "tab-1" });
});
it("stays silent whenever the target tab is on screen", () => {
// This is the #9902 regression guard: the tab itself shows the result and the
// error, and an AI action must never add a toast on top of it.
for (const outcome of [started(true), started(false, "boom"), { status: "applied" as const, tabId: "tab-1" }]) {
expect(aiSqlActionFeedback(outcome, { targetOnScreen: true, tabTitle: "MySQL@app" })).toEqual({ level: "none" });
}
});
it("falls back to the tab id when the tab has no display title", () => {
expect(aiSqlActionFeedback(started(true), { targetOnScreen: false })).toMatchObject({ params: { title: "tab-1" } });
});
it("reports apply without an execution summary", () => {
expect(aiSqlActionFeedback({ status: "applied", tabId: "tab-1" }, { targetOnScreen: false, tabTitle: "MySQL@app" })).toEqual({
level: "info",
messageKey: "ai.sqlActionAppliedIn",
params: { title: "MySQL@app" },
revealTabId: "tab-1",
});
});
it("never activates anything itself: the reveal id is data, not an action", () => {
// The mapper has no activation entry point at all — App.vue wires
// `revealTabId` to the toast's [查看] button, and only that click switches.
const feedback = aiSqlActionFeedback(started(true), { targetOnScreen: false, tabTitle: "t" });
expect(Object.keys(feedback).sort()).toEqual(["level", "messageKey", "params", "revealTabId"]);
});
it("does not add a second notice when the user already saw a dialog or toast", () => {
// Danger / production / parameter / read-only dialogs, the blocked-Redis
// toast and the "select a database" toast are all produced elsewhere.
for (const reason of ["cancelled", "parameters", "blocked", "missing-database"] as const) {
expect(aiSqlActionFeedback({ status: "not-started", reason, tabId: "tab-1" }, { targetOnScreen: false, tabTitle: "MySQL@app" })).toEqual({ level: "none" });
}
});
it("makes the silent early returns visible", () => {
expect(aiSqlActionFeedback({ status: "not-started", reason: "no-connection" }, { targetOnScreen: false })).toEqual({ level: "error", messageKey: "ai.sqlActionNoConnection", params: {} });
expect(aiSqlActionFeedback({ status: "not-started", reason: "empty-sql", tabId: "tab-1" }, { targetOnScreen: false, tabTitle: "MySQL@app" })).toEqual({
level: "error",
messageKey: "ai.sqlActionEmptySql",
params: {},
revealTabId: "tab-1",
});
expect(aiSqlActionFeedback({ status: "not-started", reason: "busy", tabId: "tab-1" }, { targetOnScreen: false, tabTitle: "MySQL@app" })).toEqual({
level: "error",
messageKey: "ai.sqlActionBusy",
params: {},
revealTabId: "tab-1",
});
});
it("treats an unresolvable tab as a no-tab report even when a stale id is carried", () => {
expect(aiSqlActionFeedback({ status: "not-started", reason: "no-tab", tabId: "tab-gone" }, { targetOnScreen: false })).toMatchObject({ level: "error", messageKey: "ai.sqlActionNoConnection" });
});
it("stays silent while a dialog parks the execution", () => {
// The dialog itself is the visible surface; the caller keeps waiting and the
// resumed execution reports through App's deferred path.
for (const reason of ["parameters", "danger", "redis-confirm"] as const) {
expect(aiSqlActionFeedback({ status: "deferred", reason, tabId: "tab-1" }, { targetOnScreen: false, tabTitle: "MySQL@app" })).toEqual({ level: "none" });
}
});
it("omits the reveal id for a target that no longer exists", () => {
const feedback = aiSqlActionFeedback({ status: "not-started", reason: "empty-sql" }, { targetOnScreen: false });
expect(feedback).toEqual({ level: "error", messageKey: "ai.sqlActionEmptySql", params: {} });
expect("revealTabId" in feedback).toBe(false);
});
});
@@ -0,0 +1,117 @@
/**
* Feedback decision for an AI-initiated SQL action (#11962).
*
* The AI panel's execute / temp-run / apply buttons hand the whole interaction
* to an editor tab resolved from the conversation's bound connection, and that
* tab is deliberately created without activation (#9902: an AI action never
* moves the workspace). When the tab is not on screen the click used to produce
* *no* visible change at all — the database error was written only into that
* background tab's result. This module decides what the panel must say instead.
*
* Pure and dependency-free on purpose: the decision table is unit-tested
* without mounting `App.vue`.
*/
import type { SqlExecutionOutcome } from "@/lib/sql/sqlExecutionOutcome";
/** One AI-initiated action's outcome: a real execution, or the panel's own append. */
export type AiSqlActionOutcome = SqlExecutionOutcome | { status: "applied"; tabId: string };
export interface AiSqlActionFeedbackContext {
/** Whether the target tab is currently rendered as some editor group's active tab. */
targetOnScreen: boolean;
/** Display title of the target tab, for the "executed in …" copy. */
tabTitle?: string;
}
export type AiSqlActionFeedback = { level: "none" } | { level: "info" | "error"; messageKey: string; params: Record<string, string>; revealTabId?: string };
const NONE: AiSqlActionFeedback = { level: "none" };
/**
* Decides the panel-side feedback for one AI SQL action.
*
* - The user already saw UI (danger/production/parameter/unlock dialogs, the
* blocked-Redis toast, the "select a database" toast) → `none`. Repeating it
* as a second toast would be noise, and the panel's own block status still
* settles so nothing is left spinning.
* - Target tab on screen → `none`: the tab itself shows the result and error,
* and #9902 promises the workspace is never disturbed by an AI action.
* - Target tab off screen → the error path is always visible, and the success
* path is announced with a `[查看]` reveal action; never an automatic switch.
*/
export function aiSqlActionFeedback(outcome: AiSqlActionOutcome, context: AiSqlActionFeedbackContext): AiSqlActionFeedback {
if (outcome.status === "not-started") {
switch (outcome.reason) {
case "cancelled":
case "parameters":
case "blocked":
// A dialog/toast the user is already looking at, or a prompt they will
// still answer — the AI action has not been abandoned, so say nothing.
return NONE;
case "missing-database":
// `promptActiveDatabaseSelection` in App.vue already toasts
// `editor.selectDatabaseRequired` for this exact request.
return NONE;
case "empty-sql":
return reveal({ level: "error", messageKey: "ai.sqlActionEmptySql", params: {} }, outcome.tabId);
case "busy":
return reveal({ level: "error", messageKey: "ai.sqlActionBusy", params: {} }, outcome.tabId);
case "no-tab":
case "no-connection":
// No target tab at all (unbound conversation, deleted connection, or a
// tab that vanished): report the reason, and there is nothing to reveal.
return { level: "error", messageKey: "ai.sqlActionNoConnection", params: {} };
}
}
if (outcome.status === "deferred") {
// The dialog is on screen and the execution has not run yet, so there is
// nothing to announce: the caller keeps waiting and settles from the resumed
// execution (`onDeferredExecutionSettled`) instead.
return NONE;
}
if (context.targetOnScreen) return NONE;
const title = context.tabTitle?.trim() || outcome.tabId;
if (outcome.status === "applied") {
return { level: "info", messageKey: "ai.sqlActionAppliedIn", params: { title }, revealTabId: outcome.tabId };
}
if (outcome.ok) {
// The *In keys name the tab the SQL ran in: the user is looking at the panel,
// not at the editor, so the toast has to say where the result landed.
return { level: "info", messageKey: "ai.sqlActionSucceededIn", params: { title }, revealTabId: outcome.tabId };
}
return {
level: "error",
messageKey: "ai.sqlActionFailed",
params: { title, message: outcome.errorMessage?.trim() || "" },
revealTabId: outcome.tabId,
};
}
function reveal(feedback: { level: "info" | "error"; messageKey: string; params: Record<string, string> }, tabId: string | undefined): AiSqlActionFeedback {
return tabId ? { ...feedback, revealTabId: tabId } : feedback;
}
/**
* Settlement report the host pushes back into the AI panel (#11962), so the SQL
* block that started the action shows its own outcome without the user having to
* find the target tab. Messages are already-translated text.
*/
export interface AiSqlActionReport {
requestId: number;
/**
* `dismissed` means the user answered a dialog (or the request was cancelled)
* and the panel must simply stop showing "running" — no error, no summary.
* `applied` is the append-to-editor action, which has no execution summary.
*/
status: "succeeded" | "applied" | "failed" | "unavailable" | "dismissed";
/** Result rows returned, when the execution produced any. */
rows?: number;
/** Rows the statement affected, when the backend reported any. */
affectedRows?: number;
/** Database error text (`failed`) or why nothing ran (`unavailable`). */
message?: string;
}
@@ -0,0 +1,34 @@
/**
* What one editor execution actually did (#11962).
*
* The editor path ignores this value — its feedback is the tab's own result pane
* — but a caller that starts an execution on a tab the user is *not* looking at
* (the AI panel does, see `lib/ai/aiSqlActionFeedback`) has nowhere else to learn
* that the request ran, ran late, or never started. Reporting every early return
* is the point: silence there is exactly the bug in #11962.
*/
/**
* Why an execution request never reached the database.
*
* `cancelled` / `parameters` / `blocked` / `missing-database` are the reasons the
* user has already been shown a dialog or toast, so a caller must not report them
* a second time.
*/
export type SqlExecutionNotStartedReason = "no-tab" | "no-connection" | "empty-sql" | "missing-database" | "parameters" | "busy" | "cancelled" | "blocked";
/** Dialog an execution is parked on until the user answers it. */
export type SqlExecutionDeferredReason = "parameters" | "danger" | "redis-confirm";
export type SqlExecutionOutcome =
/** The request reached the backend; `ok` is whether it succeeded. */
| { status: "started"; ok: boolean; errorMessage?: string; tabId: string }
/**
* The request opened a dialog (parameter / danger / Redis confirmation) and only
* continues if the user accepts it, so this is NOT terminal: a caller that needs
* the final result must wait for the resumed execution
* (`onDeferredExecutionSettled`) instead of settling here. Treating it as a
* plain dismissal is what left a confirmed AI execution unreported.
*/
| { status: "deferred"; reason: SqlExecutionDeferredReason; tabId: string }
| { status: "not-started"; reason: SqlExecutionNotStartedReason; tabId?: string };
+14
View File
@@ -169,6 +169,20 @@ Redis 连接改用只读命令工具,而不是 SQL。助手可以执行 DBX
<Callout type="info">AI Agent 不会获得“以后都可以写”的授权,也不会绕过 MCP 策略、连接只读保护或数据库权限。更多边界见 [生产环境安全](/cn/docs/production-safety)。</Callout>
## SQL 代码块操作
回复里的每个 SQL 代码块都提供 **临时运行**、**立即执行** 和 **应用到编辑器**。三者都作用于该对话**自己绑定**的连接、数据库和 Schema 对应的查询页签,而不是当前恰好在前台的那个编辑器。
该页签不存在时会在后台创建,动作本身也**不会移动你的工作区**:AI 动作不会自行切换、选中或打开任何页签。
正因为结果不总落在你眼前,代码块会自己报告结果:
* 块内显示 **执行中…**,结束后显示返回或影响的行数,失败则显示数据库返回的错误文本。
* 目标页签不在屏幕上时,提示会写明是哪个页签并提供 **查看**;只有点击 **查看** 才会切过去。
* 根本没跑起来的情况(没有可用连接、页签仍在处理上一个请求、没有可执行的 SQL)也会在块内说明原因。
危险 SQL、生产库、参数和只读解锁仍然使用各自的确认对话框,代码块和提示不会重复一遍。
## 推理强度和 Thinking
不同供应商使用不同机制:枚举 effort、reasoning level、布尔 thinking、整数预算或自定义文本。DBX 按当前模型能力展示可用选项,并记住每个配置/模型的选择。
+14
View File
@@ -171,6 +171,20 @@ Redis connections use a read-only command tool instead of SQL. The assistant can
<Callout type="info">AI Agent never receives “write from now on” permission and cannot bypass MCP policy, connection read-only protection, or database privileges. See [Production Safety](/en/docs/production-safety) for the complete boundary.</Callout>
## SQL Block Actions
Each SQL block in a reply offers **Temp run**, **Execute**, and **Apply to editor**. All three target the query tab that belongs to the conversation's own connection, database, and schema — the connection the conversation is bound to, never whichever editor happens to be in front.
That tab is created in the background when it does not exist yet, and the action never moves your workspace: an AI action does not switch, select, or open tabs on its own.
Because the result therefore does not always land where you are looking, the block reports its own outcome:
* The block shows **Executing…**, then the row count (returned or affected) or the database's error text.
* When the target tab is not on screen, a notification names that tab and offers **View**; only clicking **View** brings it to the front.
* Requests that never reached the database say why in the block instead: no available connection, an editor tab still busy with another request, or nothing to execute.
Dangerous-SQL, production, parameter, and read-only confirmations keep their own dialogs; those are not repeated in the block or as a notification.
## Reasoning Effort and Thinking
Providers expose different mechanisms: enumerated effort, reasoning levels, boolean thinking, integer budgets, or free-text options. DBX shows controls from the active model capability and remembers the selection per configuration and model.