mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(ai): report ai sql action results and errors in pane
This commit is contained in:
+178
-13
@@ -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')">
|
||||
|
||||
+195
@@ -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>({
|
||||
|
||||
@@ -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"] },
|
||||
{
|
||||
|
||||
@@ -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ə",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "実行を確認",
|
||||
|
||||
@@ -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: "실행 확인",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Подтвердить выполнение",
|
||||
|
||||
@@ -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: "ยืนยันการเรียกใช้",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "确认执行",
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 按当前模型能力展示可用选项,并记住每个配置/模型的选择。
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user