mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(ai): add opt-in auto intent routing for the assistant
This commit is contained in:
@@ -80,7 +80,7 @@ dev: node_modules/.modules.yaml check-tauri-dev-port
|
||||
$(PNPM) dev:tauri
|
||||
|
||||
dev-fast: node_modules/.modules.yaml check-tauri-dev-port
|
||||
$(PNPM) tauri dev -- --no-default-features --features duckdb-sidecar,dynamodb,sqlite-bundled
|
||||
RUST_MIN_STACK=16777216 $(PNPM) tauri dev -- --no-default-features --features duckdb-sidecar,dynamodb,sqlite-bundled
|
||||
|
||||
dev-web: node_modules/.modules.yaml
|
||||
$(PNPM) dev:web
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
Table2,
|
||||
Play,
|
||||
Square,
|
||||
Sparkles,
|
||||
Star,
|
||||
Trash2,
|
||||
Terminal,
|
||||
@@ -79,8 +80,10 @@ import {
|
||||
runAgentStream,
|
||||
isVectorDbType,
|
||||
isValidActionForMode,
|
||||
isAutoActionSelection,
|
||||
defaultActionForMode,
|
||||
type AiAction,
|
||||
type AiActionSelection,
|
||||
type AiAssistantMode,
|
||||
type AiCsvFileContext,
|
||||
type AiTextAttachmentEncoding,
|
||||
@@ -167,6 +170,7 @@ import { handleAiTableReferenceDropEvent } from "@/lib/ai/aiTableReferenceDrop";
|
||||
import { DBX_TABLE_REFERENCE_DROP_EVENT, clearActiveTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
|
||||
import { canSubmitAiPrompt, isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/lib/ai/aiPromptKeyboard";
|
||||
import { isActionableWriteProposalMessage, isActionableWriteSqlProposal, looksLikeActionProposal, looksLikeWriteSqlProposal, shouldGrantWriteSqlOnShortAffirmative } from "@/lib/ai/aiProposalDetect";
|
||||
import { classifyIntentByLlm, routeIntentByRules, type AiIntentRouteInput } from "@/lib/ai/aiIntentRouter";
|
||||
import { visibleToActualIndex } from "@/lib/ai/aiMessageEdit";
|
||||
import { shouldShowReasoningCharCount, reasoningCharCountClass } from "@/lib/ai/aiReasoningPresentation";
|
||||
import { saveTextFile } from "@/lib/export/saveTextFile";
|
||||
@@ -262,6 +266,13 @@ interface ChatMessage {
|
||||
kind?: "contextSummary" | "writeSqlConfirmation" | "productionWriteBlocked";
|
||||
/** Per-message token stats from the last agent run; ephemeral, not persisted. */
|
||||
tokens?: { input: number; output: number };
|
||||
/**
|
||||
* Set on the user message that was sent while the picker stood on `auto`,
|
||||
* together with the concrete action the router resolved (#9118). Ephemeral
|
||||
* (like `tokens`): it only drives the "Auto · <action>" chip.
|
||||
*/
|
||||
routedFrom?: "auto";
|
||||
routedAction?: AiAction;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -286,7 +297,12 @@ const prompt = ref("");
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const isGenerating = ref(false);
|
||||
const scrollRef = ref<InstanceType<typeof ScrollArea> | null>(null);
|
||||
const activeAction = ref<AiAction>("general");
|
||||
// Stays a concrete action until the AI config loads: whether new conversations
|
||||
// land on the `auto` entry is decided by the Settings > AI toggle (see
|
||||
// `resolveDefaultActionSelection`).
|
||||
const activeAction = ref<AiActionSelection>("general");
|
||||
/** True while the Auto slow path (intent classifier) is in flight for the visible conversation. */
|
||||
const routingInFlight = ref(false);
|
||||
const assistantMode = ref<AiAssistantMode>("ask");
|
||||
// The selection is loaded asynchronously. Apply it once when this panel mounts,
|
||||
// but do not let later setting changes alter an active conversation.
|
||||
@@ -299,6 +315,9 @@ watch(
|
||||
(loaded) => {
|
||||
if (loaded && !defaultModeInitialized) {
|
||||
assistantMode.value = settings.defaultAiMode;
|
||||
// Same apply-once rule as the mode: later setting changes must not
|
||||
// disturb an active conversation.
|
||||
activeAction.value = resolveDefaultActionSelection(settings.defaultAiMode);
|
||||
defaultModeInitialized = true;
|
||||
}
|
||||
},
|
||||
@@ -347,14 +366,14 @@ const unreadConversations = reactive(new Set<string>());
|
||||
* occupies it. Persisted via `AiConversation.queuedInput` and restored after a
|
||||
* restart. The mode/action are in-memory only (they default to the current
|
||||
* view after a restart). */
|
||||
type QueuedConversationInput = { text: string; mode: AiAssistantMode; action: AiAction };
|
||||
type QueuedConversationInput = { text: string; mode: AiAssistantMode; action: AiActionSelection };
|
||||
const queuedInputs = reactive(new Map<string, QueuedConversationInput>());
|
||||
|
||||
/** Auto-send/retry work waiting to enter the normal send pipeline. More than
|
||||
* one background run can settle in the same event turn; this must be FIFO, not
|
||||
* a single "next send" slot, or the later completion silently drops the first
|
||||
* conversation's queued input. */
|
||||
type PendingAutoSend = { conversationId: string; text: string; messages: ChatMessage[]; mode: AiAssistantMode; action: AiAction };
|
||||
type PendingAutoSend = { conversationId: string; text: string; messages: ChatMessage[]; mode: AiAssistantMode; action: AiActionSelection };
|
||||
const pendingAutoSends: PendingAutoSend[] = [];
|
||||
|
||||
/** Highest event `seq` the user has read per conversation (parent PRD §8). Set
|
||||
@@ -1105,14 +1124,23 @@ const AI_SQL_FILE_MENTION_CANDIDATE_LIMIT = 50;
|
||||
const AI_SQL_FILE_CONTEXT_MAX_CHARS = 12_000;
|
||||
|
||||
interface AiActionButton {
|
||||
action: AiAction;
|
||||
action: AiActionSelection;
|
||||
icon: Component;
|
||||
/** i18n key for the menu label. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `auto` entry (#9118) heads both lists: it keeps every explicit action
|
||||
* available while giving users one entry that routes the request to whichever
|
||||
* action fits (see `resolveAutoAction`). It is UI-only — `ASK_ACTIONS` /
|
||||
* `AGENT_ACTIONS` stay free of it.
|
||||
*/
|
||||
const autoActionButton: AiActionButton = { action: "auto", icon: Sparkles, key: "ai.actions.auto" };
|
||||
|
||||
/** Ask-mode actions: SQL-producing, never auto-run. */
|
||||
const askActionButtons: AiActionButton[] = [
|
||||
autoActionButton,
|
||||
{ action: "general", icon: MessageSquarePlus, key: "ai.actions.general" },
|
||||
{ action: "generate", icon: Wand2, key: "ai.actions.generate" },
|
||||
{ action: "explain", icon: HelpCircle, key: "ai.actions.explain" },
|
||||
@@ -1124,6 +1152,7 @@ const askActionButtons: AiActionButton[] = [
|
||||
|
||||
/** Agent-mode actions: task-oriented, drive tool use and real results. */
|
||||
const agentActionButtons: AiActionButton[] = [
|
||||
autoActionButton,
|
||||
{ action: "general", icon: MessageSquarePlus, key: "ai.actions.general" },
|
||||
{ action: "query", icon: Search, key: "ai.actions.query" },
|
||||
{ action: "exploreSchema", icon: Table2, key: "ai.actions.exploreSchema" },
|
||||
@@ -1142,8 +1171,18 @@ function resolveDefaultAction(mode: AiAssistantMode): AiAction {
|
||||
return defaultActionForMode(mode);
|
||||
}
|
||||
|
||||
// The Settings > AI toggle lets new conversations land on the `auto` picker
|
||||
// entry (#9118); routing still resolves to a concrete action before any
|
||||
// request. Vector DBs keep the concrete `generate` action because their action
|
||||
// menu is hidden entirely.
|
||||
function resolveDefaultActionSelection(mode: AiAssistantMode): AiActionSelection {
|
||||
if (settings.defaultAutoRouting && !(props.connection && isVectorDbType(props.connection.db_type))) return "auto";
|
||||
return resolveDefaultAction(mode);
|
||||
}
|
||||
|
||||
// Switching mode is a deliberate context change: land on that mode's default action so the
|
||||
// menu and behavior match the new intent. The shared `general` action is the default.
|
||||
// menu and behavior match the new intent. That default is the mode's concrete action, or
|
||||
// `auto` when Settings > AI enables it (#9118).
|
||||
//
|
||||
// `triggerAction` may set the action itself after programmatically switching mode (e.g. "Fix
|
||||
// with AI" invoked from Agent mode); `suppressModeActionReset` tells this watch to skip the
|
||||
@@ -1154,7 +1193,7 @@ watch(assistantMode, (mode) => {
|
||||
suppressModeActionReset = false;
|
||||
return;
|
||||
}
|
||||
activeAction.value = resolveDefaultAction(mode);
|
||||
activeAction.value = resolveDefaultActionSelection(mode);
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -1169,7 +1208,7 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function selectAction(action: AiAction) {
|
||||
function selectAction(action: AiActionSelection) {
|
||||
activeAction.value = action;
|
||||
if (action === "fix" && props.tab?.result) {
|
||||
if (isQueryExecutionErrorResult(props.tab.result)) {
|
||||
@@ -1179,6 +1218,64 @@ function selectAction(action: AiAction) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Menu label of a concrete action, mapped through either mode's button list. */
|
||||
function actionLabelFor(action: AiAction | null | undefined): string {
|
||||
if (!action) return "";
|
||||
const button = [...actionButtons.value, ...askActionButtons, ...agentActionButtons].find((item) => item.action === action);
|
||||
return button ? t(button.key) : action;
|
||||
}
|
||||
|
||||
/** The action the router resolved for an `auto` message, if any (drives the chip). */
|
||||
function routedActionOf(message: ChatMessage): AiAction | null {
|
||||
return message.routedFrom === "auto" && message.routedAction ? message.routedAction : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Auto · <action>" chip switches the picker to the action that was routed
|
||||
* for that message, so the user can keep iterating explicitly. The routed action
|
||||
* is always valid in the mode it was routed in; when the panel now stands in the
|
||||
* other mode, switch there first (the `suppressModeActionReset` handshake keeps
|
||||
* the mode-switch watch from overwriting the action we just set).
|
||||
*/
|
||||
function switchToRoutedAction(action: AiAction | null | undefined) {
|
||||
if (!action) return;
|
||||
if (!isValidActionForMode(action, assistantMode.value)) {
|
||||
suppressModeActionReset = true;
|
||||
assistantMode.value = isValidActionForMode(action, "ask") ? "ask" : "agent";
|
||||
}
|
||||
activeAction.value = action;
|
||||
}
|
||||
|
||||
/** Mirrors `buildAiContext`'s `lastError`: only a real failed execution counts. */
|
||||
function tabHasLastError(): boolean {
|
||||
const result = props.tab?.result;
|
||||
return !!result && isQueryExecutionErrorResult(result) && result.rows[0]?.[0] != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the `auto` picker entry into one concrete action (#9118), before any
|
||||
* request is assembled: the rule layer answers instantly, and only a rule miss
|
||||
* pays for a single tiny classifier completion (bounded by
|
||||
* `INTENT_CLASSIFY_TIMEOUT_MS`). The classifier never throws and falls back to
|
||||
* the mode default, so an unavailable/slow model cannot fail the send.
|
||||
*
|
||||
* `showProgress` is only true for the conversation actually on screen — a
|
||||
* background auto-send must not flash "识别中" over an unrelated chat.
|
||||
*/
|
||||
async function resolveAutoAction(text: string, mode: AiAssistantMode, showProgress: boolean): Promise<AiAction> {
|
||||
const input: AiIntentRouteInput = { text, mode, hasCurrentSql: !!props.tab?.sql.trim(), hasLastError: tabHasLastError() };
|
||||
const byRules = routeIntentByRules(input);
|
||||
if (byRules) return byRules;
|
||||
const config = activeFullConfig.value;
|
||||
if (!config) return resolveDefaultAction(mode);
|
||||
if (showProgress) routingInFlight.value = true;
|
||||
try {
|
||||
return await classifyIntentByLlm(input, { config });
|
||||
} finally {
|
||||
if (showProgress) routingInFlight.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Messages visible in the chat UI (excludes hidden context summaries). */
|
||||
const visibleMessages = computed(() => messages.value.filter((m) => m.kind !== "contextSummary"));
|
||||
|
||||
@@ -1341,7 +1438,8 @@ function sendProposalReply(positive: boolean) {
|
||||
send();
|
||||
}
|
||||
|
||||
const activePlaceholder = computed(() => `${t(`ai.placeholders.${activeAction.value}`)} ${t("ai.tableMentionPlaceholderHint")}`);
|
||||
// `auto` has no per-action placeholder of its own, so it reuses the general one.
|
||||
const activePlaceholder = computed(() => `${t(`ai.placeholders.${isAutoActionSelection(activeAction.value) ? "general" : activeAction.value}`)} ${t("ai.tableMentionPlaceholderHint")}`);
|
||||
const aiCodeAppearance = computed(() => (isDark.value ? "dark" : "light"));
|
||||
|
||||
const codeSnapshotOpen = ref(false);
|
||||
@@ -1367,14 +1465,14 @@ const modeActionTriggerLabel = computed(() => {
|
||||
});
|
||||
|
||||
function switchModeActionTab(mode: "ask" | "agent") {
|
||||
activeAction.value = resolveDefaultAction(mode);
|
||||
activeAction.value = resolveDefaultActionSelection(mode);
|
||||
if (assistantMode.value !== mode) {
|
||||
// Set the mode after the action so the tab label and picker stay aligned.
|
||||
assistantMode.value = mode;
|
||||
}
|
||||
}
|
||||
|
||||
function selectModeActionItem(action: AiAction) {
|
||||
function selectModeActionItem(action: AiActionSelection) {
|
||||
// Vector databases only support generation; keep this constraint at the selection boundary.
|
||||
if (!showActionButtons.value) return;
|
||||
selectAction(action);
|
||||
@@ -2962,7 +3060,8 @@ async function send() {
|
||||
userText: text,
|
||||
});
|
||||
|
||||
runMessages.push({ role: "user", content: text, mentions: selectedMessageMentions(selectedTableMentions, selectedSqlFiles, csvAttachments, imageAttachments), csvAttachments, imageAttachments });
|
||||
const userMessage: ChatMessage = { role: "user", content: text, mentions: selectedMessageMentions(selectedTableMentions, selectedSqlFiles, csvAttachments, imageAttachments), csvAttachments, imageAttachments };
|
||||
runMessages.push(userMessage);
|
||||
if (!auto) {
|
||||
// Save to prompt history (deduplicate consecutive duplicates)
|
||||
if (text && promptHistory.value[0] !== text) {
|
||||
@@ -2979,8 +3078,41 @@ async function send() {
|
||||
}
|
||||
if (autoSendVisible) scrollToBottom({ force: true });
|
||||
|
||||
const requestedAction = auto ? auto.action : activeAction.value;
|
||||
const requestedSelection: AiActionSelection = auto ? auto.action : activeAction.value;
|
||||
const requestedMode = auto ? auto.mode : assistantMode.value;
|
||||
// A confirmed-write turn (the ✅ reply, or the segment that resumes an
|
||||
// `awaiting_write_confirmation` run) is a continuation of the pending proposal,
|
||||
// not a new user request: its reply text is component copy, so the Auto router
|
||||
// must not classify it. Keeping the mode default there preserves the pre-Auto
|
||||
// confirmation behavior (`taskContract.action` = the picker's default) instead
|
||||
// of letting a guess describe the wrong task to the backend.
|
||||
const confirmationContinuation = allowWriteSqlForNextRun || resumingConfirmedWrite;
|
||||
// The "auto" picker entry is resolved HERE, before any request is assembled:
|
||||
// `taskContract.action` must be a concrete action (the backend interpolates it
|
||||
// into its system prompt and uses it for final-answer contract validation), and
|
||||
// `aiSkillForAction` throws on unknown actions. Explicit selections skip the
|
||||
// router entirely, so their behavior is unchanged (#9118).
|
||||
let requestedAction: AiAction;
|
||||
if (!isAutoActionSelection(requestedSelection)) {
|
||||
requestedAction = requestedSelection;
|
||||
} else if (confirmationContinuation) {
|
||||
// Nothing was routed here, so no "Auto · …" chip either (the proposal card
|
||||
// already explains what this turn does).
|
||||
requestedAction = resolveDefaultAction(requestedMode);
|
||||
} else {
|
||||
requestedAction = await resolveAutoAction(text, requestedMode, runIsVisible());
|
||||
// Keep the outcome on the message so the "Auto · <action>" chip can explain
|
||||
// (and re-select) what the router chose.
|
||||
userMessage.routedFrom = "auto";
|
||||
userMessage.routedAction = requestedAction;
|
||||
}
|
||||
// Superseded while the classifier ran (stop/clear/switch): nothing has been
|
||||
// sent to the backend yet, so bail the same way the template-load wait does.
|
||||
if (!generationCanContinue()) {
|
||||
clearPendingWriteGrant();
|
||||
resolveDetachedRunSettled();
|
||||
return;
|
||||
}
|
||||
// Detect user-typed short confirmation (e.g. "可以"/"go ahead") as an alternative
|
||||
// path to the proposal ✅ button. Delegates to the shared pure function so the
|
||||
// component and its unit tests share the same gating logic.
|
||||
@@ -3623,6 +3755,11 @@ function resetPendingRequestState() {
|
||||
stopStatusTimer();
|
||||
generationStatus.value = createGenerationStatus(Date.now());
|
||||
statusNow.value = Date.now();
|
||||
// Abandon-path cleanup for the Auto router indicator too (#9118): the classifier
|
||||
// promise clears this in its own `finally` (normal path), but a clear/switch/
|
||||
// unmount must drop it synchronously, or "识别中" stays on screen in the next
|
||||
// conversation until that bounded call settles.
|
||||
routingInFlight.value = false;
|
||||
}
|
||||
|
||||
// `alreadyCancelledSessionId`: the session id a caller (cancelStream()) has
|
||||
@@ -4204,10 +4341,11 @@ function discardRecoveredDraft() {
|
||||
function startNewChat() {
|
||||
clearMessages();
|
||||
showConversationList.value = false;
|
||||
// A fresh conversation starts from the configured default mode.
|
||||
// A fresh conversation starts from the configured default mode and, when the
|
||||
// Settings > AI toggle says so, from the `auto` picker entry (#9118).
|
||||
const mode = settings.defaultAiMode;
|
||||
assistantMode.value = mode;
|
||||
activeAction.value = resolveDefaultAction(mode);
|
||||
activeAction.value = resolveDefaultActionSelection(mode);
|
||||
}
|
||||
|
||||
/** Send-button dispatcher: with an active run on the visible conversation the
|
||||
@@ -4841,6 +4979,21 @@ async function openExternalUrl(url: string) {
|
||||
</div>
|
||||
<div v-if="msg.content" data-ai-user-message-content class="whitespace-pre-wrap">{{ msg.content }}</div>
|
||||
</div>
|
||||
<!-- "Auto" routing outcome (#9118): which action the router picked for
|
||||
this message, clickable to continue with that explicit action. -->
|
||||
<div v-if="routedActionOf(msg)" class="mt-1 flex justify-end">
|
||||
<button
|
||||
data-ai-auto-routed-chip
|
||||
type="button"
|
||||
class="inline-flex max-w-full items-center gap-1 rounded border border-border/70 bg-background/80 px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
:title="t('ai.routing.switchTo', { action: actionLabelFor(routedActionOf(msg)) })"
|
||||
:aria-label="t('ai.routing.switchTo', { action: actionLabelFor(routedActionOf(msg)) })"
|
||||
@click="switchToRoutedAction(routedActionOf(msg))"
|
||||
>
|
||||
<Sparkles class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate">{{ t("ai.routing.chip", { action: actionLabelFor(routedActionOf(msg)) }) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="canCopyMessage(msg)" class="mt-1 flex justify-end">
|
||||
<button
|
||||
data-ai-message-copy="user"
|
||||
@@ -4984,6 +5137,15 @@ async function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- "Auto" routing in flight (#9118). Only the slow path (classifier)
|
||||
shows this — the rule layer resolves instantly. Rendered while the
|
||||
user message is already visible and before the assistant
|
||||
placeholder exists, so the send never looks stuck. -->
|
||||
<div v-if="routingInFlight" data-ai-auto-routing class="flex min-w-0 items-center gap-[7px] text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3 w-3 shrink-0 animate-spin" aria-hidden="true" />
|
||||
<span class="truncate">{{ t("ai.routing.recognizing") }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Live generation-status line (Issue #6743 feature 1). Replaces the old
|
||||
"Thinking..." placeholder and covers the WHOLE generation period
|
||||
(`v-if="isGenerating"`), not just the wait for the first token. The
|
||||
|
||||
@@ -8315,6 +8315,22 @@ LIMIT 100;</pre
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default auto intent routing (list mode, global) -->
|
||||
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
|
||||
<Separator />
|
||||
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="ai-default-auto-routing">
|
||||
{{ t("ai.defaultAutoRouting") }}
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("ai.defaultAutoRoutingDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="ai-default-auto-routing" :model-value="settingsStore.defaultAutoRouting" @update:model-value="(value) => settingsStore.setDefaultAutoRouting(Boolean(value))" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore last AI conversation (list mode, global) -->
|
||||
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
|
||||
<Separator />
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// #9118: the AI assistant exposes one "Auto" entry at the head of both action
|
||||
// menus. These assertions pin the parts of the wiring that a behavioral unit
|
||||
// test cannot reach (the component owns a large amount of shared state):
|
||||
//
|
||||
// - every explicit action stays in the menu, "auto" is only prepended;
|
||||
// - the selection starts concrete; Auto is preselected on new conversations
|
||||
// only through the Settings > AI "default auto routing" toggle;
|
||||
// - "auto" is resolved to a concrete action BEFORE the request is assembled, so
|
||||
// `taskContract.action` never carries it (the backend interpolates that value
|
||||
// into its system prompt and uses it for final-answer contract validation);
|
||||
// - the vector-DB branch (hidden picker, forced `generate`) is untouched;
|
||||
// - external explicit triggers (`triggerAction`) bypass the router.
|
||||
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
|
||||
|
||||
function bodyOf(fnSignature: string): string {
|
||||
const start = source.indexOf(fnSignature);
|
||||
expect(start, `expected to find "${fnSignature}" in AiAssistant.vue`).toBeGreaterThanOrEqual(0);
|
||||
const braceStart = source.indexOf("{", start);
|
||||
let depth = 0;
|
||||
for (let i = braceStart; i < source.length; i++) {
|
||||
if (source[i] === "{") depth++;
|
||||
else if (source[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return source.slice(braceStart, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(`unbalanced braces reading body of "${fnSignature}"`);
|
||||
}
|
||||
|
||||
describe("Auto picker entry", () => {
|
||||
it("heads both action lists without removing any explicit action", () => {
|
||||
const askList = source.slice(source.indexOf("const askActionButtons: AiActionButton[] = ["), source.indexOf("const agentActionButtons: AiActionButton[] = ["));
|
||||
const agentList = source.slice(source.indexOf("const agentActionButtons: AiActionButton[] = ["), source.indexOf("const actionButtons = computed"));
|
||||
|
||||
for (const [label, list] of [
|
||||
["ask", askList],
|
||||
["agent", agentList],
|
||||
] as const) {
|
||||
const autoIdx = list.indexOf("autoActionButton,");
|
||||
expect(autoIdx, `${label}: auto entry`).toBeGreaterThanOrEqual(0);
|
||||
expect(autoIdx, `${label}: auto must be first`).toBeLessThan(list.indexOf('action: "general"'));
|
||||
// The pre-existing actions are all still present.
|
||||
expect(list, `${label}: general`).toContain('action: "general"');
|
||||
expect(list, `${label}: generate`).toContain('action: "generate"');
|
||||
}
|
||||
expect(askList).toContain('action: "sampleData"');
|
||||
expect(askList).toContain('action: "convert"');
|
||||
expect(agentList).toContain('action: "executeAndExplain"');
|
||||
expect(agentList).toContain('action: "exploreSchema"');
|
||||
expect(agentList).toContain('action: "query"');
|
||||
});
|
||||
|
||||
it("widens the picker type instead of the transport action union", () => {
|
||||
// The menu entry uses the UI-only `AiActionSelection`; `AiAction` (what the
|
||||
// request carries) stays a concrete action.
|
||||
const buttonInterface = bodyOf("interface AiActionButton {");
|
||||
expect(buttonInterface).toContain("action: AiActionSelection;");
|
||||
expect(buttonInterface).not.toContain("action: AiAction;");
|
||||
expect(source).toContain('import { classifyIntentByLlm, routeIntentByRules, type AiIntentRouteInput } from "@/lib/ai/aiIntentRouter";');
|
||||
expect(source).toContain("type AiActionSelection,");
|
||||
});
|
||||
|
||||
it("keeps resolveDefaultAction concrete; auto preselection flows only through the settings toggle", () => {
|
||||
expect(source).toContain('const activeAction = ref<AiActionSelection>("general");');
|
||||
const resolveDefault = bodyOf("function resolveDefaultAction(mode: AiAssistantMode): AiAction");
|
||||
expect(resolveDefault).not.toContain("auto");
|
||||
expect(resolveDefault).toContain("defaultActionForMode(mode)");
|
||||
|
||||
const resolveSelection = bodyOf("function resolveDefaultActionSelection(mode: AiAssistantMode): AiActionSelection");
|
||||
// Auto only when the Settings > AI toggle is on, and never for vector DBs:
|
||||
// their picker is hidden and the task contract must not invite querying.
|
||||
expect(resolveSelection).toContain("settings.defaultAutoRouting");
|
||||
expect(resolveSelection.indexOf('return "auto";')).toBeGreaterThanOrEqual(0);
|
||||
expect(resolveSelection.indexOf('return "auto";')).toBeLessThan(resolveSelection.indexOf("resolveDefaultAction(mode)"));
|
||||
expect(resolveSelection).toContain("isVectorDbType(props.connection.db_type)");
|
||||
});
|
||||
|
||||
it("lands new conversations, mode switches and mount init on the settings-driven default", () => {
|
||||
expect(bodyOf("function startNewChat()")).toContain("resolveDefaultActionSelection(mode);");
|
||||
expect(bodyOf("watch(assistantMode, (mode) => {")).toContain("resolveDefaultActionSelection(mode);");
|
||||
expect(bodyOf('function switchModeActionTab(mode: "ask" | "agent") {')).toContain("resolveDefaultActionSelection(mode);");
|
||||
// Mount init applies the default exactly once (apply-once rule shared with
|
||||
// the default mode). The picker is panel-scoped: no restore path
|
||||
// (`selectConversation`) writes it, so the settings-driven default survives
|
||||
// restoring the last conversation.
|
||||
expect(source).toContain("activeAction.value = resolveDefaultActionSelection(settings.defaultAiMode);");
|
||||
});
|
||||
|
||||
it("hides the action list for vector databases and still forces generate there", () => {
|
||||
const listStart = source.indexOf("<!-- Action list -->");
|
||||
const pickerList = source.slice(listStart, source.indexOf("</PopoverContent>", listStart));
|
||||
expect(pickerList).toContain('v-for="button in actionButtons"');
|
||||
// The list (Auto included) only renders when the action picker is shown.
|
||||
const templateGuardIdx = source.lastIndexOf('<template v-if="showActionButtons">', listStart);
|
||||
expect(templateGuardIdx).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const vectorWatchStart = source.indexOf("() => props.connection?.db_type,");
|
||||
const vectorWatch = source.slice(vectorWatchStart, source.indexOf("{ immediate: true },", vectorWatchStart));
|
||||
expect(vectorWatch).toContain('activeAction.value = "generate";');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Auto routing at send time", () => {
|
||||
it("resolves the selection before the request reaches the backend", () => {
|
||||
const sendBody = bodyOf("async function send()");
|
||||
const routingIdx = sendBody.indexOf("await resolveAutoAction(");
|
||||
const streamIdx = sendBody.indexOf("await runAgentStream(");
|
||||
const contractIdx = sendBody.indexOf("action: requestedAction,");
|
||||
expect(routingIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(streamIdx).toBeGreaterThan(routingIdx);
|
||||
expect(contractIdx).toBeGreaterThan(routingIdx);
|
||||
// Auto is only routed when it was actually selected; every explicit
|
||||
// selection is passed through untouched.
|
||||
expect(sendBody).toContain("requestedAction = requestedSelection;");
|
||||
expect(sendBody).toContain("requestedAction = await resolveAutoAction(text, requestedMode, runIsVisible());");
|
||||
// A confirmed-write turn replies with component copy, so the router must not
|
||||
// run on it: the mode default keeps the pre-Auto confirmation contract.
|
||||
expect(sendBody).toContain("const confirmationContinuation = allowWriteSqlForNextRun || resumingConfirmedWrite;");
|
||||
expect(sendBody).toContain("requestedAction = resolveDefaultAction(requestedMode);");
|
||||
// A stop/clear/switch that lands during the classifier wait must not start
|
||||
// a request nobody can reach.
|
||||
expect(sendBody.indexOf("if (!generationCanContinue()) {", routingIdx)).toBeGreaterThan(routingIdx);
|
||||
});
|
||||
|
||||
it("records the routing outcome on the user message and shows the recognizing indicator", () => {
|
||||
const sendBody = bodyOf("async function send()");
|
||||
expect(sendBody).toContain('userMessage.routedFrom = "auto";');
|
||||
expect(sendBody).toContain("userMessage.routedAction = requestedAction;");
|
||||
|
||||
const resolveBody = bodyOf("async function resolveAutoAction(text: string, mode: AiAssistantMode, showProgress: boolean): Promise<AiAction>");
|
||||
// Zero-latency rule layer first; the classifier (network) only on a miss.
|
||||
expect(resolveBody.indexOf("routeIntentByRules(input)")).toBeLessThan(resolveBody.indexOf("classifyIntentByLlm(input,"));
|
||||
expect(resolveBody).toContain("if (showProgress) routingInFlight.value = true;");
|
||||
expect(resolveBody).toContain("if (showProgress) routingInFlight.value = false;");
|
||||
expect(source).toContain("data-ai-auto-routing");
|
||||
expect(source).toContain('t("ai.routing.recognizing")');
|
||||
});
|
||||
|
||||
it("renders the Auto chip on the routed message and switches back to the explicit action", () => {
|
||||
expect(source).toContain("data-ai-auto-routed-chip");
|
||||
expect(source).toContain('t("ai.routing.chip", { action: actionLabelFor(routedActionOf(msg)) })');
|
||||
|
||||
const switchBody = bodyOf("function switchToRoutedAction(action: AiAction | null | undefined)");
|
||||
// Reuses the mode-switch suppression handshake so the watch cannot reset the
|
||||
// action right after the chip set it.
|
||||
expect(switchBody.indexOf("suppressModeActionReset = true;")).toBeLessThan(switchBody.indexOf('assistantMode.value = isValidActionForMode(action, "ask") ? "ask" : "agent";'));
|
||||
expect(switchBody.indexOf('assistantMode.value = isValidActionForMode(action, "ask") ? "ask" : "agent";')).toBeLessThan(switchBody.indexOf("activeAction.value = action;"));
|
||||
});
|
||||
|
||||
it("clears the recognizing indicator on the abandon path, not just the classifier's finally", () => {
|
||||
// Dual-path rule for per-request transient state (see
|
||||
// AiAssistant.clearAndCancel.spec.ts): the classifier's `finally` covers the
|
||||
// normal path, so clearMessages()/selectConversation()/onUnmounted must drop
|
||||
// the flag synchronously via resetPendingRequestState().
|
||||
expect(bodyOf("function resetPendingRequestState()")).toContain("routingInFlight.value = false;");
|
||||
expect(bodyOf("async function resolveAutoAction(text: string, mode: AiAssistantMode, showProgress: boolean): Promise<AiAction>")).toContain("if (showProgress) routingInFlight.value = false;");
|
||||
});
|
||||
|
||||
it("keeps external explicit triggers on the direct path", () => {
|
||||
const triggerBody = bodyOf("function triggerAction(action: AiAction, instruction?: string)");
|
||||
expect(triggerBody).toContain("activeAction.value = action;");
|
||||
expect(triggerBody).not.toContain("resolveAutoAction");
|
||||
expect(triggerBody).not.toContain("routeIntentByRules");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import az from "../locales/az";
|
||||
import en from "../locales/en";
|
||||
import es from "../locales/es";
|
||||
import it_ from "../locales/it";
|
||||
import ja from "../locales/ja";
|
||||
import ko from "../locales/ko";
|
||||
import ptBR from "../locales/pt-BR";
|
||||
import tr from "../locales/tr";
|
||||
import zhCN from "../locales/zh-CN";
|
||||
import zhTW from "../locales/zh-TW";
|
||||
|
||||
const locales: Array<[string, Record<string, unknown>]> = [
|
||||
["az", az],
|
||||
["en", en],
|
||||
["es", es],
|
||||
["it", it_],
|
||||
["ja", ja],
|
||||
["ko", ko],
|
||||
["pt-BR", ptBR],
|
||||
["tr", tr],
|
||||
["zh-CN", zhCN],
|
||||
["zh-TW", zhTW],
|
||||
];
|
||||
|
||||
// Keys the "Auto" picker entry (#9118) adds to the `ai` namespace: the menu
|
||||
// entry, the slow-path status line, and the per-message routing chip. The picker
|
||||
// and the chip render in whatever locale the user runs, so a missing key would
|
||||
// leak a raw key into the toolbar.
|
||||
const AI_AUTO_ROUTING_ACTIONS_KEYS = ["auto"] as const;
|
||||
const AI_AUTO_ROUTING_KEYS = ["recognizing", "chip", "switchTo"] as const;
|
||||
// Settings > AI toggle: new conversations start on the `auto` entry.
|
||||
const AI_AUTO_ROUTING_SETTINGS_KEYS = ["defaultAutoRouting", "defaultAutoRoutingDescription"] as const;
|
||||
|
||||
describe("AI auto-routing locale parity", () => {
|
||||
it.each(locales)("%s exposes ai.actions.auto with non-empty copy", (_name, locale) => {
|
||||
const ai = (locale as { ai: { actions: Record<string, unknown> } }).ai;
|
||||
for (const key of AI_AUTO_ROUTING_ACTIONS_KEYS) {
|
||||
expect(ai.actions[key], `${_name}: ai.actions.${key}`).toBeTypeOf("string");
|
||||
expect(ai.actions[key] as string, `${_name}: ai.actions.${key}`).not.toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(locales)("%s exposes the full ai.routing.* key set with non-empty copy", (_name, locale) => {
|
||||
const routing = (locale as { ai: { routing: Record<string, unknown> } }).ai.routing;
|
||||
for (const key of AI_AUTO_ROUTING_KEYS) {
|
||||
expect(routing?.[key], `${_name}: ai.routing.${key}`).toBeTypeOf("string");
|
||||
expect(routing?.[key] as string, `${_name}: ai.routing.${key}`).not.toHaveLength(0);
|
||||
}
|
||||
// The chip interpolates the resolved action label.
|
||||
expect(routing.chip as string, `${_name}: ai.routing.chip`).toContain("{action}");
|
||||
expect(routing.switchTo as string, `${_name}: ai.routing.switchTo`).toContain("{action}");
|
||||
});
|
||||
|
||||
it.each(locales)("%s exposes the default auto-routing setting keys with non-empty copy", (_name, locale) => {
|
||||
const ai = (locale as { ai: Record<string, unknown> }).ai;
|
||||
for (const key of AI_AUTO_ROUTING_SETTINGS_KEYS) {
|
||||
expect(ai[key], `${_name}: ai.${key}`).toBeTypeOf("string");
|
||||
expect(ai[key] as string, `${_name}: ai.${key}`).not.toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3057,6 +3057,7 @@ export default withEnglishFallback({
|
||||
cursorRunFailed: "Cursor CLI gözlənilmədən bağlandı. Problemli icra faylını və ya CLI çıxışını müəyyən etmək üçün aşağıdakı xəta kodundan və diaqnostikadan istifadə edin.",
|
||||
},
|
||||
actions: {
|
||||
auto: "Avtomatik",
|
||||
general: "Ümumi",
|
||||
generate: "SQL yarat",
|
||||
explain: "SQL-i izah et",
|
||||
@@ -3069,6 +3070,11 @@ export default withEnglishFallback({
|
||||
executeAndExplain: "İşlət və izah et",
|
||||
generateNoExec: "Yarat (işlətmədən)",
|
||||
},
|
||||
routing: {
|
||||
recognizing: "Niyyət müəyyən edilir…",
|
||||
chip: "Avtomatik · {action}",
|
||||
switchTo: "{action} seçiminə keç",
|
||||
},
|
||||
placeholders: {
|
||||
general: "Mənə istənilən sualı verin...",
|
||||
generate: "Nəyi sorğulamaq istədiyinizi təsvir edin, məsələn, son 7 gündə hər istifadəçinin sifarişləri",
|
||||
@@ -3098,6 +3104,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsSaved: "Agentin gediş limiti yadda saxlanıldı",
|
||||
defaultAiMode: "Standart AI rejimi",
|
||||
defaultAiModeDescription: "Yeni AI söhbətinə başlayarkən istifadə olunan rejim. Cari söhbətdə rejimin dəyişdirilməsi bu parametri dəyişmir.",
|
||||
defaultAutoRouting: "Standart olaraq avtomatik niyyət marşrutlaşdırması",
|
||||
defaultAutoRoutingDescription: "Yeni AI söhbətləri Avtomatik seçimi ilə başlayır: AI göndərərkən niyyətinizi müəyyən edib uyğun əməliyyata yönləndirir. Cari söhbətə təsir etmir.",
|
||||
restoreLastConversation: "Son AI söhbətini bərpa et",
|
||||
restoreLastConversationDescription: "Aktiv olduqda AI köməkçisi açılarkən boş söhbət əvəzinə ən son yenilənmiş söhbət yüklənir.",
|
||||
promptTemplates: "Ssenari sorğu şablonları",
|
||||
|
||||
@@ -3191,6 +3191,7 @@ export default {
|
||||
cursorRunFailed: "Cursor CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.",
|
||||
},
|
||||
actions: {
|
||||
auto: "Auto",
|
||||
general: "General",
|
||||
generate: "Generate SQL",
|
||||
explain: "Explain SQL",
|
||||
@@ -3203,6 +3204,11 @@ export default {
|
||||
executeAndExplain: "Run & Explain",
|
||||
generateNoExec: "Generate (no run)",
|
||||
},
|
||||
routing: {
|
||||
recognizing: "Detecting intent…",
|
||||
chip: "Auto · {action}",
|
||||
switchTo: "Switch to {action}",
|
||||
},
|
||||
placeholders: {
|
||||
general: "Ask me anything...",
|
||||
generate: "Describe what you want to query, e.g. orders per user in the last 7 days",
|
||||
@@ -3232,6 +3238,8 @@ export default {
|
||||
maxAgentTurnsSaved: "Agent turn limit saved",
|
||||
defaultAiMode: "Default AI Mode",
|
||||
defaultAiModeDescription: "Mode used when starting a new AI conversation. Switching mode in the current conversation does not change this setting.",
|
||||
defaultAutoRouting: "Auto Intent Routing by Default",
|
||||
defaultAutoRoutingDescription: "Start new AI conversations with the Auto action: the assistant detects your intent at send time and routes it to a concrete action. Does not change the current conversation.",
|
||||
restoreLastConversation: "Restore last AI conversation",
|
||||
restoreLastConversationDescription: "When enabled, opening the AI assistant loads the most recently updated conversation instead of a blank one.",
|
||||
promptTemplates: "Scenario Prompt Templates",
|
||||
|
||||
@@ -3011,6 +3011,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsSaved: "Límite de turnos del agente guardado",
|
||||
defaultAiMode: "Modo de IA predeterminado",
|
||||
defaultAiModeDescription: "Modo usado al iniciar una nueva conversación de IA. Cambiar de modo en la conversación actual no modifica esta configuración.",
|
||||
defaultAutoRouting: "Enrutamiento de intención automático por defecto",
|
||||
defaultAutoRoutingDescription: "Inicia nuevas conversaciones de IA con la acción Auto: la IA detecta tu intención al enviar y la dirige a una acción concreta. No cambia la conversación actual.",
|
||||
restoreLastConversation: "Restaurar la última conversación de IA al abrir",
|
||||
restoreLastConversationDescription: "Al activarlo, el asistente de IA carga la conversación actualizada más recientemente en lugar de una conversación en blanco.",
|
||||
promptTemplates: "Plantillas de prompt por escenario",
|
||||
@@ -3158,6 +3160,7 @@ export default withEnglishFallback({
|
||||
codexCliPath: "Ruta de Codex CLI",
|
||||
codexCliPathHint: "Déjalo vacío para usar codex desde PATH. Inicia sesión por separado con codex login.",
|
||||
actions: {
|
||||
auto: "Auto",
|
||||
general: "General",
|
||||
generate: "Generar SQL",
|
||||
explain: "Explicar SQL",
|
||||
@@ -3170,6 +3173,11 @@ export default withEnglishFallback({
|
||||
executeAndExplain: "Ejecutar y explicar",
|
||||
generateNoExec: "Generar (sin ejecutar)",
|
||||
},
|
||||
routing: {
|
||||
recognizing: "Detectando intención…",
|
||||
chip: "Auto · {action}",
|
||||
switchTo: "Cambiar a {action}",
|
||||
},
|
||||
placeholders: {
|
||||
general: "Pregunta lo que quieras...",
|
||||
generate: "Describe lo que quieres consultar, p. ej. pedidos por usuario en los últimos 7 días",
|
||||
|
||||
@@ -2905,6 +2905,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsSaved: "Limite di turni dell'agente salvato",
|
||||
defaultAiMode: "Modalità IA predefinita",
|
||||
defaultAiModeDescription: "Modalità usata all'avvio di una nuova conversazione IA. Cambiare modalità nella conversazione corrente non modifica questa impostazione.",
|
||||
defaultAutoRouting: "Routing automatico delle intenzioni predefinito",
|
||||
defaultAutoRoutingDescription: "Avvia le nuove conversazioni IA con l'azione Auto: l'IA rileva l'intento all'invio e lo instrada verso un'azione concreta. Non modifica la conversazione corrente.",
|
||||
restoreLastConversation: "Ripristina l'ultima conversazione IA all'apertura",
|
||||
restoreLastConversationDescription: "Quando è attiva, l'assistente IA carica la conversazione aggiornata più di recente invece di una conversazione vuota.",
|
||||
promptTemplates: "Modelli di prompt per scenario",
|
||||
@@ -3156,6 +3158,7 @@ export default withEnglishFallback({
|
||||
qoderRunFailed: "Qoder CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare l'eseguibile o l'output non riuscito.",
|
||||
},
|
||||
actions: {
|
||||
auto: "Auto",
|
||||
general: "Generale",
|
||||
generate: "Genera SQL",
|
||||
explain: "Spiega SQL",
|
||||
@@ -3168,6 +3171,11 @@ export default withEnglishFallback({
|
||||
executeAndExplain: "Esegui e spiega",
|
||||
generateNoExec: "Genera (senza eseguire)",
|
||||
},
|
||||
routing: {
|
||||
recognizing: "Rilevamento intento…",
|
||||
chip: "Auto · {action}",
|
||||
switchTo: "Passa a {action}",
|
||||
},
|
||||
placeholders: {
|
||||
general: "Chiedi qualsiasi cosa...",
|
||||
generate: "Descrivi cosa desideri interrogare, es. ordini per utente negli ultimi 7 giorni",
|
||||
|
||||
@@ -3047,6 +3047,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsSaved: "エージェントターン上限を保存しました",
|
||||
defaultAiMode: "既定の AI モード",
|
||||
defaultAiModeDescription: "新しい AI 会話を開始するときに使用されるモードです。現在の会話でモードを切り替えてもこの設定は変更されません。",
|
||||
defaultAutoRouting: "自動インテントルーティングを既定で有効化",
|
||||
defaultAutoRoutingDescription: "新しい AI 会話を「自動」アクションで開始します。送信時に AI が意図を判定して具体的なアクションにルーティングします。現在の会話には影響しません。",
|
||||
restoreLastConversation: "起動時に最後の AI 会話を復元",
|
||||
restoreLastConversationDescription: "有効にすると、AI アシスタントを開いたときに空白の会話ではなく、最後に更新された会話を読み込みます。",
|
||||
promptTemplates: "シナリオプロンプトテンプレート",
|
||||
@@ -3182,6 +3184,7 @@ export default withEnglishFallback({
|
||||
anthropicMessagesHint: "Anthropic Messages 互換 API は通常 /v1/messages を使用します。",
|
||||
openAiCompatibleEndpointHint: "ほとんどの OpenAI 互換 API は /v1 パスプレフィックスが必要です。",
|
||||
actions: {
|
||||
auto: "自動",
|
||||
general: "一般的な質問",
|
||||
generate: "SQLを生成",
|
||||
explain: "SQLを説明",
|
||||
@@ -3194,6 +3197,11 @@ export default withEnglishFallback({
|
||||
executeAndExplain: "実行して解説",
|
||||
generateNoExec: "生成のみ(実行しない)",
|
||||
},
|
||||
routing: {
|
||||
recognizing: "意図を判定中…",
|
||||
chip: "自動 · {action}",
|
||||
switchTo: "{action}に切り替え",
|
||||
},
|
||||
placeholders: {
|
||||
general: "何でも聞いてください...",
|
||||
generate: "クエリしたい内容を説明してください(例: 過去7日間のユーザーごとの注文数)",
|
||||
|
||||
@@ -2985,6 +2985,7 @@ export default withEnglishFallback({
|
||||
qoderRunFailed: "Qoder CLI가 예기치 않게 종료되었습니다. 아래 오류 코드와 진단을 사용하여 실패한 실행 파일이나 CLI 출력을 확인하세요.",
|
||||
},
|
||||
actions: {
|
||||
auto: "자동",
|
||||
general: "일반",
|
||||
generate: "SQL 생성",
|
||||
explain: "SQL 설명",
|
||||
@@ -2997,6 +2998,11 @@ export default withEnglishFallback({
|
||||
executeAndExplain: "실행 및 설명",
|
||||
generateNoExec: "생성 (실행 안 함)",
|
||||
},
|
||||
routing: {
|
||||
recognizing: "의도 파악 중…",
|
||||
chip: "자동 · {action}",
|
||||
switchTo: "{action}(으)로 전환",
|
||||
},
|
||||
placeholders: {
|
||||
general: "무엇이든 물어보세요...",
|
||||
generate: "쿼리할 내용을 설명하세요. 예: 최근 7일간 사용자별 주문",
|
||||
@@ -3026,6 +3032,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsSaved: "에이전트 턴 제한을 저장했습니다",
|
||||
defaultAiMode: "기본 AI 모드",
|
||||
defaultAiModeDescription: "새 AI 대화를 시작할 때 사용되는 모드입니다. 현재 대화에서 모드를 전환해도 이 설정은 변경되지 않습니다.",
|
||||
defaultAutoRouting: "자동 의도 라우팅 기본 사용",
|
||||
defaultAutoRoutingDescription: "새 AI 대화를 '자동' 선택으로 시작합니다. 전송 시 AI가 의도를 파악해 적절한 기능으로 전환합니다. 현재 대화에는 영향을 주지 않습니다.",
|
||||
restoreLastConversation: "열 때 마지막 AI 대화 복원",
|
||||
restoreLastConversationDescription: "사용하면 AI 어시스턴트를 열 때 빈 대화 대신 가장 최근에 업데이트된 대화를 불러옵니다.",
|
||||
promptTemplates: "시나리오 프롬프트 템플릿",
|
||||
|
||||
@@ -3013,6 +3013,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsSaved: "Limite de turnos do agente salvo",
|
||||
defaultAiMode: "Modo de IA padrão",
|
||||
defaultAiModeDescription: "Modo usado ao iniciar uma nova conversa de IA. Alterar o modo na conversa atual não altera esta configuração.",
|
||||
defaultAutoRouting: "Roteamento automático de intenção por padrão",
|
||||
defaultAutoRoutingDescription: "Inicia novas conversas de IA com a ação Automático: a IA detecta sua intenção ao enviar e a direciona para uma ação concreta. Não altera a conversa atual.",
|
||||
restoreLastConversation: "Restaurar a última conversa de IA ao abrir",
|
||||
restoreLastConversationDescription: "Quando ativado, o assistente de IA carrega a conversa atualizada mais recentemente em vez de uma conversa vazia.",
|
||||
promptTemplates: "Modelos de prompt por cenário",
|
||||
@@ -3158,6 +3160,7 @@ export default withEnglishFallback({
|
||||
enableThinkingOff: "Desativado",
|
||||
enableThinkingHint: "Esta opção só tem efeito em APIs /chat/completions e modelos compatíveis. Quando desativada, pode reduzir significativamente o uso de tokens, mas a qualidade dos resultados gerados pode diminuir ligeiramente.",
|
||||
actions: {
|
||||
auto: "Automático",
|
||||
general: "Geral",
|
||||
generate: "Gerar SQL",
|
||||
explain: "Explicar SQL",
|
||||
@@ -3170,6 +3173,11 @@ export default withEnglishFallback({
|
||||
executeAndExplain: "Executar e explicar",
|
||||
generateNoExec: "Gerar (sem executar)",
|
||||
},
|
||||
routing: {
|
||||
recognizing: "Detectando intenção…",
|
||||
chip: "Automático · {action}",
|
||||
switchTo: "Mudar para {action}",
|
||||
},
|
||||
placeholders: {
|
||||
general: "Pergunte qualquer coisa...",
|
||||
generate: "Descreva o que você quer consultar, por exemplo, pedidos por usuário nos últimos 7 dias",
|
||||
|
||||
@@ -3030,6 +3030,7 @@ export default withEnglishFallback({
|
||||
cursorRunFailed: "Cursor CLI beklenmedik şekilde sonlandı. Hatalı çalıştırılabiliri veya CLI çıktısını belirlemek için aşağıdaki hata kodunu ve tanılamayı kullanın.",
|
||||
},
|
||||
actions: {
|
||||
auto: "Otomatik",
|
||||
general: "Genel",
|
||||
generate: "SQL Üret",
|
||||
explain: "SQL'i Açıkla",
|
||||
@@ -3042,6 +3043,11 @@ export default withEnglishFallback({
|
||||
executeAndExplain: "Çalıştır ve Açıkla",
|
||||
generateNoExec: "Üret (çalıştırma)",
|
||||
},
|
||||
routing: {
|
||||
recognizing: "Niyet algılanıyor…",
|
||||
chip: "Otomatik · {action}",
|
||||
switchTo: "{action} seçeneğine geç",
|
||||
},
|
||||
placeholders: {
|
||||
general: "Bana istediğinizi sorun...",
|
||||
generate: "Ne sorgulamak istediğinizi anlatın, örn. son 7 gündeki kullanıcı başına siparişler",
|
||||
@@ -3071,6 +3077,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsSaved: "Agent tur sınırı kaydedildi",
|
||||
defaultAiMode: "Varsayılan Yapay Zekâ Modu",
|
||||
defaultAiModeDescription: "Yeni bir yapay zekâ konuşması başlatılırken kullanılan mod. Geçerli konuşmada mod değiştirmek bu ayarı değiştirmez.",
|
||||
defaultAutoRouting: "Varsayılan olarak otomatik niyet yönlendirme",
|
||||
defaultAutoRoutingDescription: "Yeni yapay zekâ konuşmalarını Otomatik seçimiyle başlatır: yapay zekâ, gönderim sırasında niyetinizi algılar ve uygun eyleme yönlendirir. Geçerli konuşmayı değiştirmez.",
|
||||
restoreLastConversation: "Açılışta son yapay zekâ konuşmasını geri yükle",
|
||||
restoreLastConversationDescription: "Etkinleştirildiğinde, yapay zekâ asistanı boş bir konuşma yerine en son güncellenen konuşmayı yükler.",
|
||||
promptTemplates: "Senaryo İstem Şablonları",
|
||||
|
||||
@@ -3105,6 +3105,7 @@ export default withEnglishFallback({
|
||||
cursorRunFailed: "Cursor CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。",
|
||||
},
|
||||
actions: {
|
||||
auto: "自动",
|
||||
general: "通用问答",
|
||||
generate: "生成 SQL",
|
||||
explain: "解释 SQL",
|
||||
@@ -3117,6 +3118,11 @@ export default withEnglishFallback({
|
||||
executeAndExplain: "执行并解释",
|
||||
generateNoExec: "生成但不执行",
|
||||
},
|
||||
routing: {
|
||||
recognizing: "识别中…",
|
||||
chip: "自动 · {action}",
|
||||
switchTo: "切换到{action}",
|
||||
},
|
||||
placeholders: {
|
||||
general: "问我任何问题...",
|
||||
generate: "描述你想查询什么,例如:统计最近 7 天每个用户的订单数",
|
||||
@@ -3146,6 +3152,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsSaved: "Agent 回合上限已保存",
|
||||
defaultAiMode: "默认 AI 模式",
|
||||
defaultAiModeDescription: "新建 AI 对话时使用的模式。切换当前对话的模式不会修改此设置。",
|
||||
defaultAutoRouting: "默认启用自动意图路由",
|
||||
defaultAutoRoutingDescription: "新建 AI 对话时默认选择“自动”操作,发送时由 AI 识别意图并路由到具体操作。不影响当前对话。",
|
||||
restoreLastConversation: "打开时恢复最近的 AI 对话",
|
||||
restoreLastConversationDescription: "开启后,打开 AI 助手时会加载最近更新的对话,而不是显示空白新对话。",
|
||||
promptTemplates: "场景 Prompt 模板",
|
||||
|
||||
@@ -2904,6 +2904,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsSaved: "Agent 回合上限已儲存",
|
||||
defaultAiMode: "預設 AI 模式",
|
||||
defaultAiModeDescription: "建立新 AI 對話時使用的模式。切換目前對話的模式不會變更此設定。",
|
||||
defaultAutoRouting: "預設啟用自動意圖路由",
|
||||
defaultAutoRoutingDescription: "建立新 AI 對話時預設選擇「自動」動作,傳送時由 AI 辨識意圖並路由到具體動作。不影響目前對話。",
|
||||
restoreLastConversation: "開啟時恢復最近的 AI 對話",
|
||||
restoreLastConversationDescription: "開啟後,開啟 AI 助手時會載入最近更新的對話,而不是顯示空白新對話。",
|
||||
promptTemplates: "場景 Prompt 範本",
|
||||
@@ -3132,6 +3134,7 @@ export default withEnglishFallback({
|
||||
qoderRunFailed: "Qoder CLI 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的執行檔或 CLI 輸出。",
|
||||
},
|
||||
actions: {
|
||||
auto: "自動",
|
||||
general: "通用問答",
|
||||
generate: "產生 SQL",
|
||||
explain: "解釋 SQL",
|
||||
@@ -3144,6 +3147,11 @@ export default withEnglishFallback({
|
||||
executeAndExplain: "執行並解釋",
|
||||
generateNoExec: "產生但不執行",
|
||||
},
|
||||
routing: {
|
||||
recognizing: "辨識中…",
|
||||
chip: "自動 · {action}",
|
||||
switchTo: "切換為{action}",
|
||||
},
|
||||
placeholders: {
|
||||
general: "問我任何問題...",
|
||||
generate: "描述你想查詢什麼,例如:統計最近 7 天每個使用者的訂單數",
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { AGENT_ACTIONS, ASK_ACTIONS, buildAgentRequest, isValidActionForMode, type AiAction, type AiContext } from "@/lib/ai/ai";
|
||||
import { INTENT_CLASSIFY_MAX_TEXT_CHARS, actionForIntent, classifyIntentByLlm, intentCandidatesForMode, parseClassifierAction, requestsExplicitExecution, routeIntent, routeIntentByRules, type AiIntent, type AiIntentRouteInput } from "@/lib/ai/aiIntentRouter";
|
||||
import type { AiConfig } from "@/stores/settingsStore";
|
||||
|
||||
/**
|
||||
* #9118 — the "Auto" picker entry routes a send onto one existing action.
|
||||
* These cases pin the two-stage contract: the rule layer must be narrow (no
|
||||
* hijacking plain chat), the classifier branch must be candidate-bounded and
|
||||
* fail-safe, "auto" must never reach the transport layer, and Agent mode must
|
||||
* only reach an execution-capable action when the request explicitly asks to
|
||||
* run/query (an "explain"/"fix" request must never execute SQL).
|
||||
*/
|
||||
|
||||
const config = { provider: "openai", apiKey: "test", authMethod: "api-key", endpoint: "https://example.invalid", model: "model", apiStyle: "completions" } as AiConfig;
|
||||
|
||||
function input(overrides: Partial<AiIntentRouteInput> & { text: string }): AiIntentRouteInput {
|
||||
return { hasCurrentSql: false, hasLastError: false, mode: "ask", ...overrides };
|
||||
}
|
||||
|
||||
function context(overrides: Partial<AiContext> = {}): AiContext {
|
||||
return { connectionId: "conn-1", connectionName: "Postgres", databaseType: "postgres", database: "app", currentSql: "select 1", tables: [], sqlFiles: [], csvFiles: [], truncated: false, ...overrides };
|
||||
}
|
||||
|
||||
/** Gate-open sample: an unambiguous data request (查一下 anchors the execution gate). */
|
||||
const EXPLICIT_DATA_TEXT = "查一下这张表里的数据";
|
||||
|
||||
describe("routeIntentByRules — Ask mode golden cases", () => {
|
||||
// Every non-general Ask action is reachable from an unambiguous zh/en sample.
|
||||
it("routes SQL-production requests to generate", () => {
|
||||
expect(routeIntentByRules(input({ text: "帮我生成一条按用户统计订单量的 SQL" }))).toBe("generate");
|
||||
expect(routeIntentByRules(input({ text: "generate a SQL query for monthly revenue per region" }))).toBe("generate");
|
||||
});
|
||||
|
||||
it("routes explanations of the current SQL to explain", () => {
|
||||
expect(routeIntentByRules(input({ text: "解释一下这段 SQL 做了什么", hasCurrentSql: true }))).toBe("explain");
|
||||
expect(routeIntentByRules(input({ text: "explain what this query does", hasCurrentSql: true }))).toBe("explain");
|
||||
});
|
||||
|
||||
it("routes optimization requests to optimize", () => {
|
||||
expect(routeIntentByRules(input({ text: "优化一下这条 SQL 的查询性能", hasCurrentSql: true }))).toBe("optimize");
|
||||
expect(routeIntentByRules(input({ text: "optimize this query, it is too slow", hasCurrentSql: true }))).toBe("optimize");
|
||||
});
|
||||
|
||||
it("routes error reports to fix", () => {
|
||||
expect(routeIntentByRules(input({ text: "这段 SQL 报错了,帮我看看", hasLastError: true, hasCurrentSql: true }))).toBe("fix");
|
||||
expect(routeIntentByRules(input({ text: "please fix this SQL error", hasLastError: true, hasCurrentSql: true }))).toBe("fix");
|
||||
});
|
||||
|
||||
it("routes dialect rewrites to convert", () => {
|
||||
expect(routeIntentByRules(input({ text: "把这段 SQL 转成 PostgreSQL 方言", hasCurrentSql: true }))).toBe("convert");
|
||||
expect(routeIntentByRules(input({ text: "convert this query to SQL Server", hasCurrentSql: true }))).toBe("convert");
|
||||
});
|
||||
|
||||
it("routes sample/mock data requests to sampleData", () => {
|
||||
expect(routeIntentByRules(input({ text: "生成一批测试数据插入到订单表" }))).toBe("sampleData");
|
||||
expect(routeIntentByRules(input({ text: "create sample data for the orders table" }))).toBe("sampleData");
|
||||
});
|
||||
|
||||
it("maps run-and-explain onto explain in Ask mode (Ask never executes)", () => {
|
||||
expect(routeIntentByRules(input({ text: "执行这段 SQL 并解释结果", hasCurrentSql: true }))).toBe("explain");
|
||||
});
|
||||
|
||||
it("maps data-query wording onto generate in Ask mode (Ask produces SQL, never runs it)", () => {
|
||||
expect(routeIntentByRules(input({ text: "查询订单表里最近 7 天的订单" }))).toBe("generate");
|
||||
expect(routeIntentByRules(input({ text: "fetch the latest orders for each customer" }))).toBe("generate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeIntentByRules — Agent mode golden cases", () => {
|
||||
it("routes data queries to query", () => {
|
||||
expect(routeIntentByRules(input({ text: "查询订单表里最近 7 天的订单", mode: "agent" }))).toBe("query");
|
||||
expect(routeIntentByRules(input({ text: "fetch the latest orders for each customer", mode: "agent" }))).toBe("query");
|
||||
});
|
||||
|
||||
it("routes schema questions to exploreSchema", () => {
|
||||
expect(routeIntentByRules(input({ text: "这个库里有哪些表", mode: "agent" }))).toBe("exploreSchema");
|
||||
expect(routeIntentByRules(input({ text: "list tables in this database", mode: "agent" }))).toBe("exploreSchema");
|
||||
});
|
||||
|
||||
it("routes run-and-explain requests to executeAndExplain", () => {
|
||||
expect(routeIntentByRules(input({ text: "执行这段 SQL 并解释结果", hasCurrentSql: true, mode: "agent" }))).toBe("executeAndExplain");
|
||||
expect(routeIntentByRules(input({ text: "run this query and explain the results", hasCurrentSql: true, mode: "agent" }))).toBe("executeAndExplain");
|
||||
});
|
||||
|
||||
it("routes SQL-only production to generate", () => {
|
||||
expect(routeIntentByRules(input({ text: "写一条按用户统计订单量的 SQL", mode: "agent" }))).toBe("generate");
|
||||
expect(routeIntentByRules(input({ text: "write a SQL query that counts orders per user", mode: "agent" }))).toBe("generate");
|
||||
});
|
||||
|
||||
it("maps Ask-style (non-execution) intents onto non-executing Agent actions", () => {
|
||||
// Safety (#9118 review): an explanation request must NOT reach an
|
||||
// execution-capable Agent action — Auto would run SQL the user never asked
|
||||
// to run. `general` explains without an execute contract; `generate`'s
|
||||
// SQL-first output contract would be wrong for an explanation.
|
||||
const explained = routeIntentByRules(input({ text: "解释一下这段 SQL 做了什么", hasCurrentSql: true, mode: "agent" }));
|
||||
expect(explained).toBe("general");
|
||||
expect(explained).not.toBe("executeAndExplain");
|
||||
expect(explained).not.toBe("query");
|
||||
|
||||
// Error repair produces corrected SQL; it does not investigate by running it.
|
||||
const fixed = routeIntentByRules(input({ text: "这段 SQL 报错了", hasLastError: true, hasCurrentSql: true, mode: "agent" }));
|
||||
expect(fixed).toBe("generate");
|
||||
expect(fixed).not.toBe("query");
|
||||
expect(fixed).not.toBe("executeAndExplain");
|
||||
|
||||
expect(routeIntentByRules(input({ text: "优化一下这条 SQL", hasCurrentSql: true, mode: "agent" }))).toBe("generate");
|
||||
expect(routeIntentByRules(input({ text: "把这段 SQL 转成 MySQL", hasCurrentSql: true, mode: "agent" }))).toBe("generate");
|
||||
expect(routeIntentByRules(input({ text: "生成一批测试数据", mode: "agent" }))).toBe("generate");
|
||||
});
|
||||
|
||||
it("keeps every non-execution intent on a non-executing Agent action (table-level)", () => {
|
||||
// Guards the mapping TABLE itself, not just the samples above: reverting
|
||||
// `explain → executeAndExplain` / `fix → query` fails here even if the
|
||||
// sample-based expectations were edited to match the reverted behavior.
|
||||
const executionActions: AiAction[] = ["query", "executeAndExplain"];
|
||||
const nonExecutionIntents: AiIntent[] = ["generate", "explain", "optimize", "fix", "convert", "sampleData", "exploreSchema"];
|
||||
for (const intent of nonExecutionIntents) {
|
||||
const action = actionForIntent(intent, "agent");
|
||||
expect(executionActions, `${intent} → ${action}`).not.toContain(action);
|
||||
expect(isValidActionForMode(action, "agent"), `${intent} → ${action}`).toBe(true);
|
||||
}
|
||||
// The only execution-capable intents are the ones the guard also gates on.
|
||||
expect(actionForIntent("query", "agent")).toBe("query");
|
||||
expect(actionForIntent("executeAndExplain", "agent")).toBe("executeAndExplain");
|
||||
});
|
||||
|
||||
it("keeps an explanation of the current query outside the execution gate", () => {
|
||||
// `查询` is also a noun in a static SQL explanation. It must not admit
|
||||
// execution-capable actions unless the request names a data target.
|
||||
expect(requestsExplicitExecution("解释一下这个查询")).toBe(false);
|
||||
expect(routeIntentByRules(input({ text: "解释一下这个查询", mode: "agent" }))).toBe("general");
|
||||
});
|
||||
|
||||
it("still routes explicitly execution-asking requests to the execution actions", () => {
|
||||
// Guard against over-tightening: the invariant gates on an EXPLICIT
|
||||
// run/query request, and those keep their tool-backed actions.
|
||||
for (const text of ["查询订单表里最近 7 天的订单", "fetch the latest orders", "查一下今天的新增用户"]) {
|
||||
expect(routeIntentByRules(input({ text, mode: "agent" })), text).toBe("query");
|
||||
}
|
||||
for (const text of ["执行这段 SQL 并解释结果", "run this query and explain the results"]) {
|
||||
expect(routeIntentByRules(input({ text, hasCurrentSql: true, mode: "agent" })), text).toBe("executeAndExplain");
|
||||
}
|
||||
});
|
||||
|
||||
it("never returns an execution-capable Agent action for a non-run request", () => {
|
||||
const nonRunSamples = [
|
||||
"解释一下这段 SQL 做了什么",
|
||||
"这段 SQL 报错了",
|
||||
"优化一下这条 SQL",
|
||||
"把这段 SQL 转成 MySQL",
|
||||
"生成一批测试数据",
|
||||
"写一条统计订单的 SQL",
|
||||
"这个库里有哪些表",
|
||||
"你好",
|
||||
"Show me how to create a table",
|
||||
"Get help writing a stored procedure",
|
||||
"查询语句的执行顺序是什么?",
|
||||
"查询语句如何使用索引?",
|
||||
];
|
||||
for (const text of nonRunSamples) {
|
||||
const routed = routeIntentByRules(input({ text, hasCurrentSql: true, hasLastError: true, mode: "agent" }));
|
||||
expect(["query", "executeAndExplain"], `${text} → ${routed}`).not.toContain(routed);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every Ask-mode mapping non-executing", () => {
|
||||
expect(routeIntentByRules(input({ text: "查询订单数据" }))).toBe("generate");
|
||||
expect(routeIntentByRules(input({ text: "这个库里有哪些表" }))).toBe("generate");
|
||||
expect(routeIntentByRules(input({ text: "执行这段 SQL 并解释结果", hasCurrentSql: true }))).toBe("explain");
|
||||
});
|
||||
|
||||
it("only ever returns actions valid for the current mode", () => {
|
||||
const samples = ["帮我生成一条 SQL", "解释一下这段 SQL", "优化这条 SQL 性能", "这段 SQL 报错了", "转成 MySQL 方言", "生成测试数据", "查询订单数据", "这个库里有哪些表", "执行这段 SQL 并解释结果", "你好"];
|
||||
for (const mode of ["ask", "agent"] as const) {
|
||||
for (const text of samples) {
|
||||
const routed = routeIntentByRules(input({ text, hasCurrentSql: true, hasLastError: true, mode }));
|
||||
if (routed) expect(isValidActionForMode(routed, mode), `${mode}/${text} → ${routed}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestsExplicitExecution — narrow execution gate", () => {
|
||||
it("does not open for explanatory/code-generation uses of broad verbs (P1 regression)", () => {
|
||||
// The gate is deliberately narrower than the rule layer's permissive
|
||||
// routing vocabulary: show/get/find/list (看看/获取/找一下/列出) also
|
||||
// introduce explanations and code-generation requests, which must never
|
||||
// make execution-capable actions available to the classifier.
|
||||
for (const text of [
|
||||
"Show me how to create a table",
|
||||
"Get help writing a stored procedure",
|
||||
"list the pros and cons of composite indexes",
|
||||
"find the best join order for this query",
|
||||
"看看别人是怎么写分页的",
|
||||
"获取一下连接超时的处理建议",
|
||||
"找一下有没有现成的分页写法",
|
||||
"查询语句的执行顺序是什么?",
|
||||
"查询语句如何使用索引?",
|
||||
]) {
|
||||
expect(requestsExplicitExecution(text), text).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens for unambiguous run/query-data phrasing in both languages", () => {
|
||||
for (const text of ["run this", "execute the migration", "fetch the latest orders", "query the order records", "查询订单表里最近 7 天的订单", "查一下今天的新增用户", "统计一下各状态的订单数"]) {
|
||||
expect(requestsExplicitExecution(text), text).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("routes table-listing wording onto the non-executing schema action", () => {
|
||||
// Canonical broad-verb data requests are answered by exploreSchema
|
||||
// (list_tables) rather than the broad query bucket, so narrowing the gate
|
||||
// does not degrade them into classifier guesses.
|
||||
expect(routeIntentByRules(input({ text: "show me the tables", mode: "agent" }))).toBe("exploreSchema");
|
||||
expect(routeIntentByRules(input({ text: "列出所有表", mode: "agent" }))).toBe("exploreSchema");
|
||||
expect(routeIntentByRules(input({ text: "列出所有表" }))).toBe("generate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeIntentByRules — context signals and narrowness", () => {
|
||||
it("does not route a plain chat message that merely contains '优化'", () => {
|
||||
expect(routeIntentByRules(input({ text: "帮我优化一下下周的会议安排" }))).toBeNull();
|
||||
expect(routeIntentByRules(input({ text: "how can I optimize my team workflow?" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("requires SQL context for optimize/explain, but an explicit SQL object counts", () => {
|
||||
expect(routeIntentByRules(input({ text: "优化一下这条 SQL" }))).toBe("optimize");
|
||||
expect(routeIntentByRules(input({ text: "解释一下这个查询" }))).toBe("explain");
|
||||
expect(routeIntentByRules(input({ text: "优化一下" }))).toBeNull();
|
||||
expect(routeIntentByRules(input({ text: "explain this" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a failed run plus error wording as a fix signal", () => {
|
||||
expect(routeIntentByRules(input({ text: "为什么这次执行出错了", hasLastError: true }))).toBe("fix");
|
||||
expect(routeIntentByRules(input({ text: "为什么慢", hasLastError: true }))).toBeNull();
|
||||
});
|
||||
|
||||
it("does not let an unrelated failed run hijack an explicit dialect request", () => {
|
||||
expect(routeIntentByRules(input({ text: "把这段 SQL 转成 MySQL", hasCurrentSql: true, hasLastError: true }))).toBe("convert");
|
||||
});
|
||||
|
||||
it("falls through for greetings, thanks and empty text", () => {
|
||||
expect(routeIntentByRules(input({ text: "你好" }))).toBeNull();
|
||||
expect(routeIntentByRules(input({ text: "hello there, nice to meet you" }))).toBeNull();
|
||||
expect(routeIntentByRules(input({ text: "谢谢" }))).toBeNull();
|
||||
expect(routeIntentByRules(input({ text: " " }))).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a short confirmation/refusal reply as a continuation, not a new intent", () => {
|
||||
// "可以" answers the previous proposal (aiProposalDetect): the mode default
|
||||
// is used without a classifier call, so a write confirmation never stalls.
|
||||
expect(routeIntentByRules(input({ text: "可以", hasCurrentSql: true, mode: "agent" }))).toBe("general");
|
||||
expect(routeIntentByRules(input({ text: "go ahead", mode: "agent" }))).toBe("general");
|
||||
expect(routeIntentByRules(input({ text: "不用了" }))).toBe("general");
|
||||
// A real request that merely starts with a confirmation word is still routed.
|
||||
expect(routeIntentByRules(input({ text: "可以帮我生成一条统计订单的 SQL" }))).toBe("generate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyIntentByLlm (slow path)", () => {
|
||||
it("accepts a strict JSON answer from the candidate set", async () => {
|
||||
// EXPLICIT_DATA_TEXT is an explicit data request, so the execution actions
|
||||
// stay in the candidate set here (see the execution-invariant block below).
|
||||
const complete = vi.fn().mockResolvedValue('{"action":"query"}');
|
||||
await expect(classifyIntentByLlm(input({ text: EXPLICIT_DATA_TEXT, mode: "agent" }), { config, complete })).resolves.toBe("query");
|
||||
});
|
||||
|
||||
it("only offers the current mode's actions to the model", async () => {
|
||||
const askComplete = vi.fn().mockResolvedValue('{"action":"general"}');
|
||||
await classifyIntentByLlm(input({ text: EXPLICIT_DATA_TEXT, mode: "ask" }), { config, complete: askComplete });
|
||||
const askPrompt = askComplete.mock.calls[0][0].systemPrompt as string;
|
||||
expect(askPrompt).toContain(`Allowed actions: ${ASK_ACTIONS.join(", ")}.`);
|
||||
expect(askPrompt).not.toContain("executeAndExplain");
|
||||
|
||||
const agentComplete = vi.fn().mockResolvedValue('{"action":"general"}');
|
||||
await classifyIntentByLlm(input({ text: EXPLICIT_DATA_TEXT, mode: "agent" }), { config, complete: agentComplete });
|
||||
const agentPrompt = agentComplete.mock.calls[0][0].systemPrompt as string;
|
||||
expect(agentPrompt).toContain(`Allowed actions: ${AGENT_ACTIONS.join(", ")}.`);
|
||||
expect(agentPrompt).not.toContain("sampleData");
|
||||
});
|
||||
|
||||
it("sends no history, no task contract and only the trimmed user text", async () => {
|
||||
const complete = vi.fn().mockResolvedValue('{"action":"general"}');
|
||||
const longText = "a".repeat(INTENT_CLASSIFY_MAX_TEXT_CHARS + 500);
|
||||
await classifyIntentByLlm(input({ text: ` ${longText} `, mode: "ask" }), { config, complete });
|
||||
const request = complete.mock.calls[0][0];
|
||||
expect(request.taskContract).toBeUndefined();
|
||||
expect(request.messages).toHaveLength(1);
|
||||
expect(request.messages[0]).toEqual({ role: "user", content: longText.slice(0, INTENT_CLASSIFY_MAX_TEXT_CHARS) });
|
||||
expect(request.maxTokens).toBeLessThanOrEqual(64);
|
||||
expect(request.systemPrompt).not.toContain(longText);
|
||||
});
|
||||
|
||||
it("falls back to general on prose, malformed JSON, unknown and out-of-candidate actions", async () => {
|
||||
for (const answer of ["I think you want to query data", "", "not json", '{"action":"nonsense"}', '{"action":42}']) {
|
||||
const complete = vi.fn().mockResolvedValue(answer);
|
||||
await expect(classifyIntentByLlm(input({ text: EXPLICIT_DATA_TEXT, mode: "agent" }), { config, complete })).resolves.toBe("general");
|
||||
}
|
||||
// `query` is a valid Agent action but not an Ask one.
|
||||
const complete = vi.fn().mockResolvedValue('{"action":"query"}');
|
||||
await expect(classifyIntentByLlm(input({ text: EXPLICIT_DATA_TEXT, mode: "ask" }), { config, complete })).resolves.toBe("general");
|
||||
});
|
||||
|
||||
it("falls back to general when the classifier rejects or times out", async () => {
|
||||
const failing = vi.fn().mockRejectedValue(new Error("provider down"));
|
||||
await expect(classifyIntentByLlm(input({ text: EXPLICIT_DATA_TEXT }), { config, complete: failing })).resolves.toBe("general");
|
||||
|
||||
const hanging = vi.fn().mockImplementation(() => new Promise<string>(() => {}));
|
||||
await expect(classifyIntentByLlm(input({ text: EXPLICIT_DATA_TEXT }), { config, complete: hanging, timeoutMs: 5 })).resolves.toBe("general");
|
||||
});
|
||||
|
||||
it("skips the call entirely when there is no text to classify", async () => {
|
||||
const complete = vi.fn().mockResolvedValue('{"action":"query"}');
|
||||
await expect(classifyIntentByLlm(input({ text: " ", mode: "agent" }), { config, complete })).resolves.toBe("general");
|
||||
expect(complete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the plain completion path (never the agent loop) by default", async () => {
|
||||
const complete = vi.spyOn(api, "aiComplete").mockResolvedValue('{"action":"optimize"}');
|
||||
const agentStream = vi.spyOn(api, "aiAgentStream").mockResolvedValue("done");
|
||||
try {
|
||||
await expect(classifyIntentByLlm(input({ text: "优化一下这段 SQL", hasCurrentSql: true }), { config })).resolves.toBe("optimize");
|
||||
expect(complete).toHaveBeenCalledTimes(1);
|
||||
expect(agentStream).not.toHaveBeenCalled();
|
||||
const request = complete.mock.calls[0][0];
|
||||
expect(request.taskContract).toBeUndefined();
|
||||
expect(request.config).toBe(config);
|
||||
} finally {
|
||||
complete.mockRestore();
|
||||
agentStream.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyIntentByLlm — Agent execution invariant", () => {
|
||||
const NON_RUN_TEXT = "帮我把这段 SQL 描述得更清楚一点";
|
||||
const RUN_TEXT = "查一下今天的新增用户";
|
||||
|
||||
it("keeps execution actions out of the candidate prompt for a non-run request", async () => {
|
||||
expect(requestsExplicitExecution(NON_RUN_TEXT)).toBe(false);
|
||||
const complete = vi.fn().mockResolvedValue('{"action":"general"}');
|
||||
await classifyIntentByLlm(input({ text: NON_RUN_TEXT, hasCurrentSql: true, mode: "agent" }), { config, complete });
|
||||
const prompt = complete.mock.calls[0][0].systemPrompt as string;
|
||||
expect(prompt).toContain("Allowed actions: general, exploreSchema, generate.");
|
||||
expect(prompt).not.toContain("executeAndExplain");
|
||||
expect(prompt).not.toMatch(/Allowed actions:[^.]*\bquery\b/);
|
||||
});
|
||||
|
||||
it("rejects a hallucinated execution pick for a non-run request", async () => {
|
||||
for (const answer of ['{"action":"query"}', '{"action":"executeAndExplain"}']) {
|
||||
const complete = vi.fn().mockResolvedValue(answer);
|
||||
await expect(classifyIntentByLlm(input({ text: NON_RUN_TEXT, hasCurrentSql: true, mode: "agent" }), { config, complete }), answer).resolves.toBe("general");
|
||||
}
|
||||
// The same answers are honored when the user did ask to run.
|
||||
const runComplete = vi.fn().mockResolvedValue('{"action":"query"}');
|
||||
await expect(classifyIntentByLlm(input({ text: RUN_TEXT, mode: "agent" }), { config, complete: runComplete })).resolves.toBe("query");
|
||||
const runPrompt = runComplete.mock.calls[0][0].systemPrompt as string;
|
||||
expect(runPrompt).toContain(`Allowed actions: ${AGENT_ACTIONS.join(", ")}.`);
|
||||
});
|
||||
|
||||
it("keeps execution actions unreachable for explanatory show/get requests (P1 regression)", async () => {
|
||||
// Review finding: "Show me how to create a table" matched the broad
|
||||
// data-request vocabulary, which used to admit `query` into the classifier
|
||||
// candidates. The narrow gate must keep it closed end to end — rule layer
|
||||
// AND classifier — even against a hostile `{"action":"query"}` answer.
|
||||
for (const text of ["Show me how to create a table", "Get help writing a stored procedure", "查询语句的执行顺序是什么?", "查询语句如何使用索引?"]) {
|
||||
expect(requestsExplicitExecution(text), text).toBe(false);
|
||||
const rules = routeIntentByRules(input({ text, mode: "agent" }));
|
||||
expect(rules === null || !["query", "executeAndExplain"].includes(rules as AiAction), `${text} → ${rules}`).toBe(true);
|
||||
const complete = vi.fn().mockResolvedValue('{"action":"query"}');
|
||||
const resolved = await routeIntent(input({ text, mode: "agent" }), { config, complete });
|
||||
expect(resolved, text).toBe("general");
|
||||
const prompt = complete.mock.calls[0][0].systemPrompt as string;
|
||||
expect(prompt).toContain("Allowed actions: general, exploreSchema, generate.");
|
||||
}
|
||||
});
|
||||
|
||||
it("filters the candidate list programmatically, for both modes", () => {
|
||||
const agentDefault = intentCandidatesForMode("agent", true);
|
||||
expect(agentDefault).toEqual(AGENT_ACTIONS);
|
||||
const agentReduced = intentCandidatesForMode("agent", false);
|
||||
expect(agentReduced).toEqual(["general", "exploreSchema", "generate"]);
|
||||
expect(agentReduced).not.toContain("query");
|
||||
expect(agentReduced).not.toContain("executeAndExplain");
|
||||
// Ask mode has no execution-capable action, so nothing changes there.
|
||||
expect(intentCandidatesForMode("ask", false)).toEqual(ASK_ACTIONS);
|
||||
expect(intentCandidatesForMode("ask", true)).toEqual(ASK_ACTIONS);
|
||||
});
|
||||
|
||||
it("never resolves an execution action through routeIntent for a non-run request", async () => {
|
||||
// The rule layer misses this text, so the (restricted) classifier answers.
|
||||
const complete = vi.fn().mockResolvedValue('{"action":"executeAndExplain"}');
|
||||
const resolved = await routeIntent(input({ text: NON_RUN_TEXT, hasCurrentSql: true, mode: "agent" }), { config, complete });
|
||||
expect(resolved).toBe("general");
|
||||
expect(complete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseClassifierAction", () => {
|
||||
it("extracts the action from a JSON object even when wrapped in prose or a fence", () => {
|
||||
expect(parseClassifierAction('```json\n{"action":"fix"}\n```', "ask")).toBe("fix");
|
||||
expect(parseClassifierAction('Sure: {"action":"optimize"}', "ask")).toBe("optimize");
|
||||
expect(parseClassifierAction('{"action":" fix "}', "ask")).toBe("fix");
|
||||
});
|
||||
|
||||
it("rejects answers outside the mode's candidate set", () => {
|
||||
expect(parseClassifierAction('{"action":"auto"}', "ask")).toBeNull();
|
||||
expect(parseClassifierAction('{"action":"exploreSchema"}', "ask")).toBeNull();
|
||||
expect(parseClassifierAction('{"action":"exploreSchema"}', "agent")).toBe("exploreSchema");
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeIntent (two-stage entry point)", () => {
|
||||
it("never calls the model when the rule layer already matched", async () => {
|
||||
const complete = vi.fn().mockResolvedValue('{"action":"general"}');
|
||||
await expect(routeIntent(input({ text: "把这段 SQL 转成 PostgreSQL", hasCurrentSql: true }), { config, complete })).resolves.toBe("convert");
|
||||
expect(complete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("consults the classifier only after a rule miss", async () => {
|
||||
const complete = vi.fn().mockResolvedValue('{"action":"exploreSchema"}');
|
||||
// "帮我总结一下这份报告" matches no rule pattern, so the slow path runs.
|
||||
await expect(routeIntent(input({ text: "帮我总结一下这份报告", mode: "agent" }), { config, complete })).resolves.toBe("exploreSchema");
|
||||
expect(complete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("'auto' never reaches the transport layer", () => {
|
||||
it("keeps the concrete action tables free of 'auto'", () => {
|
||||
expect(ASK_ACTIONS).not.toContain("auto");
|
||||
expect(AGENT_ACTIONS).not.toContain("auto");
|
||||
expect(isValidActionForMode("auto" as unknown as AiAction, "ask")).toBe(false);
|
||||
expect(isValidActionForMode("auto" as unknown as AiAction, "agent")).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves every routed action into a concrete taskContract.action", async () => {
|
||||
const samples = ["帮我生成一条 SQL", "解释一下这段 SQL", "优化这条 SQL 性能", "这段 SQL 报错了", "转成 MySQL 方言", "生成测试数据", "查询订单数据", "这个库里有哪些表", "执行这段 SQL 并解释结果"];
|
||||
for (const mode of ["ask", "agent"] as const) {
|
||||
for (const text of samples) {
|
||||
const routeInput = input({ text, hasCurrentSql: true, hasLastError: true, mode });
|
||||
const resolved = (await routeIntent(routeInput, { config, complete: vi.fn().mockResolvedValue('{"action":"general"}') })) as AiAction;
|
||||
expect(isValidActionForMode(resolved, mode)).toBe(true);
|
||||
const request = buildAgentRequest({ config, action: resolved, mode, instruction: text, context: context() }, undefined, undefined);
|
||||
expect(request.taskContract.action).toBe(resolved);
|
||||
expect(request.taskContract.action).not.toBe("auto");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("never puts an Agent execution action on the wire for an explain/fix request", async () => {
|
||||
// End-to-end shape of the safety finding: the resolved action is what lands
|
||||
// in `taskContract.action`, so it must be non-executing for these inputs.
|
||||
// Both are resolved by the rule layer (the injected classifier answer is a
|
||||
// deliberately hostile "execute!" pick), and the classifier-only path is
|
||||
// covered by the execution-invariant block above.
|
||||
const cases = [
|
||||
{ text: "解释一下这段 SQL 做了什么", answer: '{"action":"executeAndExplain"}' },
|
||||
{ text: "这段 SQL 报错了", answer: '{"action":"query"}' },
|
||||
];
|
||||
for (const { text, answer } of cases) {
|
||||
const resolved = await routeIntent(input({ text, hasCurrentSql: true, hasLastError: true, mode: "agent" }), { config, complete: vi.fn().mockResolvedValue(answer) });
|
||||
expect(["query", "executeAndExplain"], `${text} → ${resolved}`).not.toContain(resolved);
|
||||
const request = buildAgentRequest({ config, action: resolved, mode: "agent", instruction: text, context: context() }, undefined, undefined);
|
||||
expect(request.taskContract.action).toBe(resolved);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,22 @@ export function isValidActionForMode(action: AiAction, mode: AiAssistantMode): b
|
||||
return (mode === "agent" ? AGENT_ACTIONS : ASK_ACTIONS).includes(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* UI-level picker selection: every concrete transport action plus the `auto`
|
||||
* entry that lets the assistant pick one of them at send time (#9118).
|
||||
*
|
||||
* `auto` deliberately stays OUT of `AiAction`, `ASK_ACTIONS`/`AGENT_ACTIONS` and
|
||||
* `isValidActionForMode`: it is resolved by `aiIntentRouter` before
|
||||
* `buildAgentRequest`/`runAgentStream` run, so an "auto" string can never reach
|
||||
* `AiTaskContract.action` — the backend interpolates that value into its system
|
||||
* prompt and uses it for `validate_final_answer`/contract repair.
|
||||
*/
|
||||
export type AiActionSelection = AiAction | "auto";
|
||||
|
||||
export function isAutoActionSelection(selection: AiActionSelection): selection is "auto" {
|
||||
return selection === "auto";
|
||||
}
|
||||
|
||||
function isChineseLocale(locale: Locale): boolean {
|
||||
return locale === "zh-CN" || locale === "zh-TW";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { AiCompletionRequest } from "@/lib/backend/tauri";
|
||||
import { ASK_ACTIONS, AGENT_ACTIONS, defaultActionForMode, isValidActionForMode, type AiAction } from "@/lib/ai/ai";
|
||||
import { isShortAffirmative, isShortNegative } from "@/lib/ai/aiProposalDetect";
|
||||
import type { AiConfig } from "@/stores/settingsStore";
|
||||
import type { AiAssistantMode } from "@/types/ai";
|
||||
|
||||
/**
|
||||
* Intent router behind the assistant's "Auto" picker entry (#9118).
|
||||
*
|
||||
* Two stages, cheapest first:
|
||||
*
|
||||
* 1. {@link routeIntentByRules} — pure zh/en heuristics, zero latency, zero
|
||||
* network. Deliberately narrow in the style of `aiProposalDetect.ts`: an
|
||||
* intent verb only routes when it co-occurs with an actionable object or a
|
||||
* context signal (`hasCurrentSql` / `hasLastError`), so a chat message that
|
||||
* merely contains "优化" does not hijack the request.
|
||||
* 2. {@link classifyIntentByLlm} — one lightweight plain-completion call
|
||||
* (`ai_complete`, never the agent loop) that must answer with strict JSON.
|
||||
* Its candidate set is the current mode's action list, minus the Agent-mode
|
||||
* execution-capable actions unless the request explicitly asks to run, and
|
||||
* any failure (timeout, transport error, unparsable or out-of-candidate
|
||||
* answer) falls back to the mode's default `general`.
|
||||
*
|
||||
* The router only ever returns a concrete `AiAction`; "auto" never leaves the
|
||||
* UI, and the resolved action is always valid for the mode it was routed in.
|
||||
*
|
||||
* **Safety invariant**: in Agent mode an execution-capable action (`query`,
|
||||
* `executeAndExplain`) is only selectable when the request explicitly asks to
|
||||
* run/query/execute/fetch data. Ask-style intents (explain/optimize/convert/…)
|
||||
* map to non-executing actions only — Auto must never turn "explain this SQL"
|
||||
* into real database execution.
|
||||
*/
|
||||
|
||||
/** Intents the rule layer can recognize before mapping them onto concrete actions. */
|
||||
export type AiIntent = "generate" | "explain" | "optimize" | "fix" | "convert" | "sampleData" | "query" | "exploreSchema" | "executeAndExplain";
|
||||
|
||||
export interface AiIntentRouteInput {
|
||||
/** Raw text the user typed for this turn. */
|
||||
text: string;
|
||||
/** The editor has SQL/command text to work on. */
|
||||
hasCurrentSql: boolean;
|
||||
/** The last execution failed — an error report is a hard routing signal. */
|
||||
hasLastError: boolean;
|
||||
mode: AiAssistantMode;
|
||||
}
|
||||
|
||||
/** Hard cap on the classifier timeout; a slow model must never delay the real request. */
|
||||
export const INTENT_CLASSIFY_TIMEOUT_MS = 3000;
|
||||
/** The classifier only needs the user's phrasing — never schema, results or error payloads. */
|
||||
export const INTENT_CLASSIFY_MAX_TEXT_CHARS = 500;
|
||||
const INTENT_CLASSIFY_MAX_TOKENS = 32;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rule layer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Phrase sets are picked by script (the aiProposalDetect.ts convention) so a
|
||||
// zh sentence and an en sentence never have to share one regex.
|
||||
const ZH_GENERATE_VERBS = [/生成/, /写一?[条个段]/, /创建/, /构造/, /拼一?条/, /来一?条/, /产出/];
|
||||
const EN_GENERATE_VERBS = [/\bgenerate\b/i, /\bwrite\b/i, /\bcreate\b/i, /\bproduce\b/i, /\bbuild\b/i, /\bdraft\b/i];
|
||||
const ZH_SQL_OBJECTS = [/sql/i, /语句/, /查询/, /脚本/, /代码/, /命令/];
|
||||
const EN_SQL_OBJECTS = [/\bsql\b/i, /\bquery\b/i, /\bstatement\b/i, /\bscript\b/i, /\bcode\b/i, /\bcommand\b/i];
|
||||
|
||||
const ZH_EXPLAIN_VERBS = [/解释/, /说明/, /讲解/, /说清楚/, /什么意思/, /含义/, /做什么用/];
|
||||
const EN_EXPLAIN_VERBS = [/\bexplain\b/i, /\bwhat does\b/i, /\bwhat is this\b/i, /\bwalk me through\b/i, /\bdescribe\b/i, /\bmeaning\b/i];
|
||||
|
||||
const ZH_OPTIMIZE_VERBS = [/优化/, /调优/, /提升性能/, /性能/, /加速/, /更高效/, /慢/];
|
||||
const EN_OPTIMIZE_VERBS = [/\boptimize\b/i, /\boptimise\b/i, /\btune\b/i, /\bspeed up\b/i, /\bslower\b/i, /\bfaster\b/i, /\bperformance\b/i, /\bslow\b/i];
|
||||
|
||||
// An error report, not a bare "why" question.
|
||||
const ERROR_REPORT_RE = /报错|出错|错误|异常|执行失败|运行失败|失败|无法执行|fix|error|failed|failure|exception|stack ?trace/i;
|
||||
const FIX_REQUEST_RE = /修复|修一下|修一修|改正|纠正|排查|调试|解决|fix|debug|troubleshoot|correct/i;
|
||||
|
||||
const ZH_CONVERT_VERBS = [/转成/, /转换/, /转为/, /改成/, /改为/, /换成/, /译成/, /翻译成/, /改写成/];
|
||||
const EN_CONVERT_VERBS = [/\bconvert\b/i, /\btranslate\b/i, /\brewrite\b/i, /\bport\b/i, /\bmigrate\b/i];
|
||||
// A named target dialect is what makes a convert request actionable.
|
||||
const DIALECT_NAME_RE = /\b(?:mysql|mariadb|postgres(?:ql)?|oracle|sql\s*server|sqlserver|mssql|sqlite|clickhouse|duckdb|hive|spark|trino|presto|redshift|snowflake|bigquery|tidb|oceanbase|db2|firebird|pg)\b|达梦|人大金仓|金仓|高斯|opengauss|kingbase/i;
|
||||
|
||||
const SAMPLE_DATA_RE = /测试数据|样例数据|示例数据|模拟数据|假数据|造数据|填充数据|演示数据|测试用数据|(?:mock|sample|dummy|fake|test|seed|demo)\s+(?:数据|data|rows|records|values)/i;
|
||||
|
||||
const ZH_EXEC_VERBS = [/执行/, /运行/, /跑一下/, /跑跑/, /试跑/];
|
||||
const EN_EXEC_VERBS = [/\brun\b/i, /\bexecute\b/i];
|
||||
const ZH_RESULT_EXPLAIN_VERBS = [/解释/, /说明/, /讲解/, /分析结果/, /看看结果/];
|
||||
const EN_RESULT_EXPLAIN_VERBS = [/\bexplain\b/i, /\bwalk me through\b/i, /\binterpret\b/i];
|
||||
|
||||
// Listing/showing tables is schema inspection, not a data query — anchoring it
|
||||
// here keeps the broad query bucket from turning it into a SELECT (Agent+Auto).
|
||||
const ZH_SCHEMA_PHRASES = [/有哪些?(?:表|字段|列|索引|视图|数据库)/, /有(?:哪|什么)些表/, /表结构/, /表清单/, /字段信息/, /列信息/, /元数据/, /数据库对象/, /列出(?:所有|全部|一下)?的?(?:(?:数据)?表|视图|索引|数据库)/, /显示(?:所有|全部|一下)?的?表/];
|
||||
const EN_SCHEMA_PHRASES = [/\b(?:what|which|list|show|name)\s+(?:me\s+|us\s+)?(?:all\s+|the\s+|these\s+)?(?:tables|views|indexes|columns|databases)\b/i, /\btable (?:structure|schema|list)\b/i, /\bschema\b/i, /\bmetadata\b/i, /\bcolumns? (?:of|in|for)\b/i];
|
||||
|
||||
const ZH_QUERY_VERBS = [/查询/, /查一下/, /查查/, /查看/, /看看/, /看下/, /看一下/, /统计/, /获取/, /拉取/, /取出?/, /列出/, /找一下/, /有多少/, /多少条/, /数量/, /汇总/, /合计/, /排行/];
|
||||
const EN_QUERY_VERBS = [/\bquery\b/i, /\bselect\b/i, /\bfetch\b/i, /\bretrieve\b/i, /\blist\b/i, /\bshow\b/i, /\bcount\b/i, /\bhow many\b/i, /\btotal\b/i, /\bsum\b/i, /\bget\b/i, /\bfind\b/i, /\btop\b/i];
|
||||
|
||||
function anyMatch(patterns: RegExp[], text: string): boolean {
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
function phrasesFor(isZh: boolean, zh: RegExp[], en: RegExp[]): RegExp[] {
|
||||
return isZh ? zh : en;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-mode actions that actually run statements against the database.
|
||||
*
|
||||
* **Safety invariant** (see `AGENT_INTENT_ACTIONS` and `intentCandidatesForMode`):
|
||||
* under Auto these may only be selected when the request EXPLICITLY asks to
|
||||
* run/query/execute data. An "explain this SQL" or "fix this error" request must
|
||||
* never trigger real execution — that would be an unrequested, potentially
|
||||
* expensive or sensitive query.
|
||||
*/
|
||||
const AGENT_EXECUTION_ACTIONS: ReadonlySet<AiAction> = new Set<AiAction>(["query", "executeAndExplain"]);
|
||||
|
||||
/**
|
||||
* Narrow, unambiguous "run it against the database" phrases — the ONLY signals
|
||||
* that may open the Agent execution gate (`requestsExplicitExecution`).
|
||||
*
|
||||
* Deliberately NOT derived from the rule layer's permissive data-request
|
||||
* vocabulary (`ZH/EN_QUERY_VERBS`): broad verbs such as show / get / find /
|
||||
* list (看看 / 获取 / 找一下 / 列出) also introduce explanation and
|
||||
* code-generation requests ("show me how to create a table", "get help
|
||||
* writing a stored procedure") that must never admit execution-capable
|
||||
* actions. The broad bucket may still route such a text onto `query`, but
|
||||
* `guardExecutionAction` re-checks every rule output against THIS gate, so in
|
||||
* Agent mode the permissive vocabulary can only ever end in a non-executing
|
||||
* action; the English data-object anchor additionally keeps bare "get/fetch"
|
||||
* from opening the gate without a target.
|
||||
*/
|
||||
// Unlike English, Chinese has no word boundary between `查询` and the subject.
|
||||
// Require a nearby data target so static questions such as “查询语句的执行顺序”
|
||||
// do not authorize database I/O merely because they contain that word.
|
||||
const ZH_EXPLICIT_DATA_QUERY_RE = /(?:查询|查一下|查查|统计|汇总|合计|排行|有多少|多少条)[^。!?;]{0,24}(?:数据|记录|订单|用户|客户|日志|指标|行数|条数|数量|总数)/;
|
||||
// `执行` and `运行` are also used in static questions such as “执行顺序” and
|
||||
// “运行机制”. Only their imperative forms, or an explicit current-SQL target,
|
||||
// authorize the execution actions.
|
||||
const ZH_EXPLICIT_RUN_RE = /(?:执行一下|运行一下|跑一跑|跑跑|试跑|(?:执行|运行)(?:这(?:条|段)|当前|该|下列|下面|此)(?:\s*(?:SQL|语句|查询|脚本))?|(?:执行|运行)\s*(?:SQL|查询|脚本))/;
|
||||
const NARROW_EXECUTION_PHRASES_ZH = [ZH_EXPLICIT_RUN_RE, ZH_EXPLICIT_DATA_QUERY_RE];
|
||||
const NARROW_EXECUTION_PHRASES_EN = [...EN_EXEC_VERBS, /\b(?:query|fetch|pull|retrieve|get|select)\b[^.!?;]{0,40}?\b(?:data|database|db|rows?|records?|results?|entries|tables?|views?|orders?|users?|customers?|logs?|events?|metrics?)\b/i];
|
||||
|
||||
/**
|
||||
* True when the text explicitly asks to run/query/execute/fetch data, phrased
|
||||
* narrowly and unambiguously. This gate decides BOTH halves of the Agent
|
||||
* execution invariant: `guardExecutionAction` filters rule-layer outputs
|
||||
* against it, and `intentCandidatesForMode` drops the execution actions from
|
||||
* the classifier candidates when it is false.
|
||||
*/
|
||||
export function requestsExplicitExecution(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
const isZh = /[一-鿿]/.test(trimmed);
|
||||
return anyMatch(phrasesFor(isZh, NARROW_EXECUTION_PHRASES_ZH, NARROW_EXECUTION_PHRASES_EN), trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent mode has no Ask-only action, so a recognized Ask-style intent maps onto
|
||||
* the closest Agent-mode equivalent. Ask-style intents map onto **non-executing**
|
||||
* actions only: an explanation request uses `general` (whose contract answers
|
||||
* without instructing execution — `generate`'s SQL-first output contract would be
|
||||
* wrong for it) and error repair uses `generate` (corrected SQL, not executed).
|
||||
* Intentional execution (`query`, `executeAndExplain`) is reachable exclusively
|
||||
* through the data-request buckets, and `guardExecutionAction` re-checks each
|
||||
* hit against the narrow execution gate before it can leave the router.
|
||||
*/
|
||||
const AGENT_INTENT_ACTIONS: Record<AiIntent, AiAction> = {
|
||||
generate: "generate",
|
||||
query: "query",
|
||||
exploreSchema: "exploreSchema",
|
||||
executeAndExplain: "executeAndExplain",
|
||||
explain: "general",
|
||||
fix: "generate",
|
||||
optimize: "generate",
|
||||
convert: "generate",
|
||||
sampleData: "generate",
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask mode produces SQL only (never runs it), so data/schema intents map onto
|
||||
* `generate` and "run & explain" onto `explain`.
|
||||
*/
|
||||
const ASK_INTENT_ACTIONS: Record<AiIntent, AiAction> = {
|
||||
generate: "generate",
|
||||
explain: "explain",
|
||||
optimize: "optimize",
|
||||
fix: "fix",
|
||||
convert: "convert",
|
||||
sampleData: "sampleData",
|
||||
query: "generate",
|
||||
exploreSchema: "generate",
|
||||
executeAndExplain: "explain",
|
||||
};
|
||||
|
||||
/** Concrete action for a detected intent in the given mode (always mode-valid). */
|
||||
export function actionForIntent(intent: AiIntent, mode: AiAssistantMode): AiAction {
|
||||
return mode === "agent" ? AGENT_INTENT_ACTIONS[intent] : ASK_INTENT_ACTIONS[intent];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure, zero-latency rule layer. Returns the concrete action for the current
|
||||
* mode, or `null` when nothing matched and the caller should fall through to the
|
||||
* classifier.
|
||||
*
|
||||
* Context signals outrank text keywords: a failed last run routes error reports
|
||||
* to `fix`, a named target dialect routes to `convert`, and sample/mock-data
|
||||
* wording routes to `sampleData` before any generic verb is considered. The
|
||||
* SQL-context-gated intents (`optimize`, `explain`) additionally require the
|
||||
* editor to hold SQL or the text to reference it, which is what keeps plain
|
||||
* conversation from being routed.
|
||||
*
|
||||
* Agent mode additionally enforces the execution invariant: an execution-capable
|
||||
* action is only ever returned by the explicit data-request buckets (the checks
|
||||
* below would already guarantee that; the wrapper's final guard makes it
|
||||
* structural so a future verb-set edit cannot silently reintroduce
|
||||
* "explain → execute").
|
||||
*/
|
||||
export function routeIntentByRules(input: AiIntentRouteInput): AiAction | null {
|
||||
return guardExecutionAction(routeByRulesUnchecked(input), input);
|
||||
}
|
||||
|
||||
/** Rule decision table, before the Agent execution guard. */
|
||||
function routeByRulesUnchecked(input: AiIntentRouteInput): AiAction | null {
|
||||
const text = input.text.trim();
|
||||
if (!text) return null;
|
||||
|
||||
// A short "可以" / "go ahead" (or its negative, see `aiProposalDetect`) answers
|
||||
// the previous assistant proposal: it is a continuation, not a new request.
|
||||
// Route it to the mode default without spending a classifier call on it — the
|
||||
// conversation history, not this turn's wording, carries the earlier action.
|
||||
if (isShortAffirmative(text) || isShortNegative(text)) return defaultActionForMode(input.mode);
|
||||
|
||||
const isZh = /[一-鿿]/.test(text);
|
||||
const sqlObject = anyMatch(phrasesFor(isZh, ZH_SQL_OBJECTS, EN_SQL_OBJECTS), text);
|
||||
const sqlContext = input.hasCurrentSql || sqlObject;
|
||||
|
||||
// --- context signals (highest priority) ---
|
||||
if (input.hasLastError && ERROR_REPORT_RE.test(text)) return actionForIntent("fix", input.mode);
|
||||
if (anyMatch(phrasesFor(isZh, ZH_CONVERT_VERBS, EN_CONVERT_VERBS), text) && DIALECT_NAME_RE.test(text)) return actionForIntent("convert", input.mode);
|
||||
if (SAMPLE_DATA_RE.test(text)) return actionForIntent("sampleData", input.mode);
|
||||
|
||||
// --- run & explain (before plain explain/query, which its wording also matches) ---
|
||||
if (anyMatch(phrasesFor(isZh, ZH_EXEC_VERBS, EN_EXEC_VERBS), text) && anyMatch(phrasesFor(isZh, ZH_RESULT_EXPLAIN_VERBS, EN_RESULT_EXPLAIN_VERBS), text)) {
|
||||
return actionForIntent("executeAndExplain", input.mode);
|
||||
}
|
||||
// --- schema inspection (before the generic query bucket) ---
|
||||
if (anyMatch(phrasesFor(isZh, ZH_SCHEMA_PHRASES, EN_SCHEMA_PHRASES), text)) return actionForIntent("exploreSchema", input.mode);
|
||||
|
||||
// --- SQL-context-gated text operations ---
|
||||
if (sqlContext && anyMatch(phrasesFor(isZh, ZH_OPTIMIZE_VERBS, EN_OPTIMIZE_VERBS), text)) return actionForIntent("optimize", input.mode);
|
||||
if (sqlContext && anyMatch(phrasesFor(isZh, ZH_EXPLAIN_VERBS, EN_EXPLAIN_VERBS), text)) return actionForIntent("explain", input.mode);
|
||||
|
||||
// --- explicit fix request without an error report yet ---
|
||||
if (anyMatch([FIX_REQUEST_RE], text) && (input.hasLastError || sqlContext)) return actionForIntent("fix", input.mode);
|
||||
|
||||
// --- text production, then the permissive data-request bucket ---
|
||||
if (anyMatch(phrasesFor(isZh, ZH_GENERATE_VERBS, EN_GENERATE_VERBS), text) && sqlObject) return actionForIntent("generate", input.mode);
|
||||
if (anyMatch(phrasesFor(isZh, ZH_QUERY_VERBS, EN_QUERY_VERBS), text)) return actionForIntent("query", input.mode);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforcement of the Agent execution invariant on every rule-layer output: an
|
||||
* execution-capable action may only leave the router when the text matches the
|
||||
* narrow explicit-execution gate. The broad data-request bucket matches
|
||||
* permissive verbs (show/get/find/list, 看看/获取/找一下) whose explanatory or
|
||||
* code-generation uses ("show me how to create a table") must never execute —
|
||||
* the guard converts those hits to `null` so the request falls through to the
|
||||
* classifier with the execution actions already excluded from its candidates.
|
||||
*/
|
||||
function guardExecutionAction(action: AiAction | null, input: AiIntentRouteInput): AiAction | null {
|
||||
if (!action) return null;
|
||||
if (input.mode !== "agent" || !AGENT_EXECUTION_ACTIONS.has(action)) return action;
|
||||
return requestsExplicitExecution(input.text) ? action : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Classifier layer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AiIntentClassifyDeps {
|
||||
config: AiConfig;
|
||||
/**
|
||||
* Injected for tests. Defaults to the plain completion path
|
||||
* (`api.aiComplete` → `ai_complete`); the agent loop is never used here.
|
||||
*/
|
||||
complete?: (request: AiCompletionRequest) => Promise<string>;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate actions for a mode, in menu order.
|
||||
*
|
||||
* In Agent mode the execution-capable actions are dropped unless the request
|
||||
* explicitly asks to run/query (`allowExecution`): the invariant is enforced by
|
||||
* removing them from the candidate list programmatically, not by asking the
|
||||
* model nicely in the prompt. `allowExecution` is deliberately NOT defaulted —
|
||||
* every caller has to decide, so a new call site cannot silently re-open
|
||||
* execution by forgetting the argument.
|
||||
*/
|
||||
export function intentCandidatesForMode(mode: AiAssistantMode, allowExecution: boolean): AiAction[] {
|
||||
const candidates = mode === "agent" ? [...AGENT_ACTIONS] : [...ASK_ACTIONS];
|
||||
if (mode === "agent" && !allowExecution) return candidates.filter((action) => !AGENT_EXECUTION_ACTIONS.has(action));
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function buildClassifierSystemPrompt(input: AiIntentRouteInput, candidates: AiAction[]): string {
|
||||
return [
|
||||
"You are an intent router for a SQL assistant inside a database IDE.",
|
||||
`Pick the single best action for the user's request. Mode: ${input.mode}.`,
|
||||
`Allowed actions: ${candidates.join(", ")}.`,
|
||||
`Context: the editor ${input.hasCurrentSql ? "contains SQL" : "is empty"}; the last execution ${input.hasLastError ? "failed" : "did not report an error"}.`,
|
||||
'Reply with JSON only: {"action":"<one allowed action>"}.',
|
||||
'If the request does not clearly match any action, reply {"action":"general"}.',
|
||||
"Never explain, never add other keys, never wrap the JSON in prose or code fences.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the classifier answer. Only an exact, mode-valid action is accepted —
|
||||
* anything else (prose, unknown action, malformed JSON) returns `null`.
|
||||
*/
|
||||
export function parseClassifierAction(raw: string, mode: AiAssistantMode): AiAction | null {
|
||||
const match = raw.match(/\{[^{}]*\}/);
|
||||
if (!match) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(match[0]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const action = (parsed as { action?: unknown }).action;
|
||||
if (typeof action !== "string") return null;
|
||||
const normalized = action.trim() as AiAction;
|
||||
return isValidActionForMode(normalized, mode) ? normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slow path: one tiny completion call constrained to the current mode's action
|
||||
* list. Resolves to the mode default (`general`) on missing user text, timeout,
|
||||
* transport error, unparsable output or an out-of-candidate answer; it never
|
||||
* rejects, so a failing classifier can never break the real request.
|
||||
*
|
||||
* Agent mode drops the execution-capable actions from the candidate list unless
|
||||
* the request explicitly asks to run/query, and the parsed answer is checked
|
||||
* against that same list: the invariant is programmatic, so neither prompt
|
||||
* wording nor a hallucinated action name can make Auto execute SQL the user
|
||||
* never asked to run.
|
||||
*/
|
||||
export async function classifyIntentByLlm(input: AiIntentRouteInput, deps: AiIntentClassifyDeps): Promise<AiAction> {
|
||||
const fallback = defaultActionForMode(input.mode);
|
||||
// Nothing to classify (attachment-only send): the classifier would only see an
|
||||
// empty user message, which some providers reject outright. Skip the call and
|
||||
// take the same default the failure path would have produced.
|
||||
if (!input.text.trim()) return fallback;
|
||||
const candidates = intentCandidatesForMode(input.mode, requestsExplicitExecution(input.text));
|
||||
if (!candidates.length) return fallback;
|
||||
const complete = deps.complete ?? ((request: AiCompletionRequest) => api.aiComplete(request));
|
||||
const timeoutMs = deps.timeoutMs ?? INTENT_CLASSIFY_TIMEOUT_MS;
|
||||
const request: AiCompletionRequest = {
|
||||
config: deps.config,
|
||||
systemPrompt: buildClassifierSystemPrompt(input, candidates),
|
||||
// No conversation history and no schema/error payload: the phrasing alone
|
||||
// is what the classifier needs, and nothing else leaves the client.
|
||||
messages: [{ role: "user", content: input.text.trim().slice(0, INTENT_CLASSIFY_MAX_TEXT_CHARS) }],
|
||||
maxTokens: INTENT_CLASSIFY_MAX_TOKENS,
|
||||
};
|
||||
|
||||
let timedOut = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<string>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
resolve("");
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
const answer = await Promise.race([Promise.resolve(complete(request)).catch(() => ""), timeout]);
|
||||
if (timedOut) return fallback;
|
||||
const parsed = parseClassifierAction(answer, input.mode);
|
||||
return parsed && candidates.includes(parsed) ? parsed : fallback;
|
||||
} catch {
|
||||
// `complete` threw synchronously.
|
||||
return fallback;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-stage routing entry point: rules first, classifier second, `general` last.
|
||||
* Always resolves to a concrete action valid for `input.mode`.
|
||||
*/
|
||||
export async function routeIntent(input: AiIntentRouteInput, deps: AiIntentClassifyDeps): Promise<AiAction> {
|
||||
const byRules = routeIntentByRules(input);
|
||||
if (byRules) return byRules;
|
||||
return classifyIntentByLlm(input, deps);
|
||||
}
|
||||
@@ -313,6 +313,7 @@ describe("settings search", () => {
|
||||
{ titleKey: "settings.exportRowLimit", category: "data", targetId: "data" },
|
||||
{ titleKey: "settings.queryExportKeysetOptimizationEnabled", category: "data", targetId: "data" },
|
||||
{ titleKey: "ai.defaultAiMode", category: "ai", targetId: "ai" },
|
||||
{ titleKey: "ai.defaultAutoRouting", category: "ai", targetId: "ai" },
|
||||
{ titleKey: "ai.maxAgentTurns", category: "ai", targetId: "ai" },
|
||||
{ titleKey: "ai.maxRetriesGlobal", category: "ai", targetId: "ai" },
|
||||
{ titleKey: "ai.globalInstructions", category: "ai", targetId: "ai" },
|
||||
|
||||
@@ -251,6 +251,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
|
||||
{ id: "ai-config", category: "ai", titleKey: "ai.configList", targetId: "ai" },
|
||||
{ id: "ai-prompts", category: "ai", titleKey: "ai.promptTemplates", descriptionKey: "ai.promptTemplatesDescription", targetId: "ai" },
|
||||
{ id: "ai-default-mode", category: "ai", titleKey: "ai.defaultAiMode", descriptionKey: "ai.defaultAiModeDescription", targetId: "ai" },
|
||||
{ id: "ai-default-auto-routing", category: "ai", titleKey: "ai.defaultAutoRouting", descriptionKey: "ai.defaultAutoRoutingDescription", targetId: "ai" },
|
||||
{ id: "ai-agent-turn-limit", category: "ai", titleKey: "ai.maxAgentTurns", descriptionKey: "ai.maxAgentTurnsDescription", targetId: "ai" },
|
||||
{ id: "ai-global-retries", category: "ai", titleKey: "ai.maxRetriesGlobal", descriptionKey: "ai.maxRetriesGlobalDescription", targetId: "ai" },
|
||||
{ id: "ai-global-instructions", category: "ai", titleKey: "ai.globalInstructions", descriptionKey: "ai.globalInstructionsDescription", targetId: "ai" },
|
||||
|
||||
@@ -1678,6 +1678,7 @@ describe("settingsStore activeModel lifecycle", () => {
|
||||
active: undefined,
|
||||
effortPreferences: [],
|
||||
defaultMode: "ask",
|
||||
defaultAutoRouting: false,
|
||||
restoreLastConversation: false,
|
||||
}),
|
||||
);
|
||||
@@ -1766,6 +1767,7 @@ describe("settingsStore activeModel lifecycle", () => {
|
||||
},
|
||||
],
|
||||
defaultMode: "ask",
|
||||
defaultAutoRouting: false,
|
||||
restoreLastConversation: false,
|
||||
});
|
||||
});
|
||||
@@ -1875,4 +1877,42 @@ describe("settingsStore defaultAiMode lifecycle", () => {
|
||||
await vi.waitFor(() => expect(saveAiChatSelection).toHaveBeenCalled());
|
||||
expect(saveAiChatSelection.mock.calls[0][0]).toMatchObject({ defaultMode: "agent" });
|
||||
});
|
||||
|
||||
it("falls back to disabled auto routing when the saved chat selection has none", async () => {
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
loadAiConfigs: vi.fn().mockResolvedValue([]),
|
||||
loadAiConfig: vi.fn().mockResolvedValue(null),
|
||||
loadAiProviderConfigs: vi.fn().mockResolvedValue(null),
|
||||
loadAiChatSelection: vi.fn().mockResolvedValue(null),
|
||||
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const store = useSettingsStore();
|
||||
|
||||
await store.initAiConfigs();
|
||||
|
||||
expect(store.defaultAutoRouting).toBe(false);
|
||||
});
|
||||
|
||||
it("restores and persists the default auto-routing preference", async () => {
|
||||
const saveAiChatSelection = vi.fn().mockResolvedValue(undefined);
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
loadAiConfigs: vi.fn().mockResolvedValue([]),
|
||||
loadAiConfig: vi.fn().mockResolvedValue(null),
|
||||
loadAiProviderConfigs: vi.fn().mockResolvedValue(null),
|
||||
loadAiChatSelection: vi.fn().mockResolvedValue({ version: 1, effortPreferences: [], defaultAutoRouting: true }),
|
||||
saveAiChatSelection,
|
||||
}));
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const store = useSettingsStore();
|
||||
await store.initAiConfigs();
|
||||
|
||||
expect(store.defaultAutoRouting).toBe(true);
|
||||
store.setDefaultAutoRouting(false);
|
||||
store.setDefaultAutoRouting(true);
|
||||
|
||||
await vi.waitFor(() => expect(saveAiChatSelection).toHaveBeenLastCalledWith(expect.objectContaining({ defaultAutoRouting: true })));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1729,6 +1729,9 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
const activeModel = ref<{ configId: string; modelId: string } | null>(null);
|
||||
const effortPreferences = ref<AiModelEffortPreference[]>([]);
|
||||
const defaultAiMode = ref<AiAssistantMode>("ask");
|
||||
// Opt-in (#9118): new conversations land on the `auto` picker entry only when
|
||||
// the user turned the default on in Settings > AI.
|
||||
const defaultAutoRouting = ref(false);
|
||||
const restoreLastConversation = ref(false);
|
||||
// Per-db_type prompt template defaults (explicit opt-in) and last-used
|
||||
// fallback; both resolved when an AI panel mounts or its namespace changes.
|
||||
@@ -1933,6 +1936,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
const savedSelection = await api.loadAiChatSelection().catch(() => null);
|
||||
effortPreferences.value = (savedSelection?.effortPreferences ?? []).filter((preference) => aiConfigs.value.some((config) => config.id === preference.configId));
|
||||
defaultAiMode.value = savedSelection?.defaultMode ?? "ask";
|
||||
defaultAutoRouting.value = savedSelection?.defaultAutoRouting ?? false;
|
||||
restoreLastConversation.value = savedSelection?.restoreLastConversation ?? false;
|
||||
aiDefaultTemplatesByDbType.value = normalizeTemplateIdsByDbType(savedSelection?.defaultTemplatesByDbType);
|
||||
aiLastUsedTemplatesByDbType.value = normalizeTemplateIdsByDbType(savedSelection?.lastUsedTemplatesByDbType);
|
||||
@@ -2074,6 +2078,12 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
persistAiChatSelection();
|
||||
}
|
||||
|
||||
function setDefaultAutoRouting(value: boolean) {
|
||||
if (value === defaultAutoRouting.value) return;
|
||||
defaultAutoRouting.value = value;
|
||||
persistAiChatSelection();
|
||||
}
|
||||
|
||||
function setRestoreLastConversation(value: boolean) {
|
||||
if (value === restoreLastConversation.value) return;
|
||||
restoreLastConversation.value = value;
|
||||
@@ -2140,6 +2150,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
selection: { ...preference.selection },
|
||||
})),
|
||||
defaultMode: defaultAiMode.value,
|
||||
defaultAutoRouting: defaultAutoRouting.value,
|
||||
restoreLastConversation: restoreLastConversation.value,
|
||||
// Match the backend's skip_serializing_if(empty): omit the per-db_type
|
||||
// records entirely while nothing is configured so the payload stays
|
||||
@@ -2533,6 +2544,8 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
activeEffort,
|
||||
defaultAiMode,
|
||||
setDefaultAiMode,
|
||||
defaultAutoRouting,
|
||||
setDefaultAutoRouting,
|
||||
restoreLastConversation,
|
||||
setRestoreLastConversation,
|
||||
aiDefaultTemplatesByDbType,
|
||||
|
||||
@@ -116,6 +116,8 @@ export interface AiChatSelectionState {
|
||||
active?: AiActiveModelSelection;
|
||||
effortPreferences: AiModelEffortPreference[];
|
||||
defaultMode?: AiAssistantMode;
|
||||
/** Whether new AI conversations start with the action picker on `auto` (#9118). */
|
||||
defaultAutoRouting?: boolean;
|
||||
/** Whether opening the AI panel restores the most recently updated conversation. */
|
||||
restoreLastConversation?: boolean;
|
||||
/** Prompt template ids auto-applied when the AI panel opens, keyed by connection db_type. */
|
||||
|
||||
@@ -333,6 +333,10 @@ pub struct AiChatSelectionState {
|
||||
pub effort_preferences: Vec<AiModelEffortPreference>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_mode: Option<AiAssistantMode>,
|
||||
/// Whether new AI conversations start with the action picker on `auto`
|
||||
/// (intent routing). Defaults to false: a concrete action stays the norm.
|
||||
#[serde(default)]
|
||||
pub default_auto_routing: bool,
|
||||
/// Whether opening the AI panel should restore the most recently updated conversation.
|
||||
#[serde(default)]
|
||||
pub restore_last_conversation: bool,
|
||||
@@ -353,6 +357,7 @@ impl Default for AiChatSelectionState {
|
||||
active: None,
|
||||
effort_preferences: Vec::new(),
|
||||
default_mode: None,
|
||||
default_auto_routing: false,
|
||||
restore_last_conversation: false,
|
||||
default_templates_by_db_type: BTreeMap::new(),
|
||||
last_used_templates_by_db_type: BTreeMap::new(),
|
||||
@@ -5213,6 +5218,9 @@ mod tests {
|
||||
let legacy: AiChatSelectionState =
|
||||
serde_json::from_str(r#"{"version":1,"active":null,"effortPreferences":[]}"#).unwrap();
|
||||
assert_eq!(legacy.default_mode, None);
|
||||
// Same for the opt-in auto-routing flag: a blob written before it existed
|
||||
// must load as false (concrete action stays the default).
|
||||
assert!(!legacy.default_auto_routing);
|
||||
|
||||
let agent: AiChatSelectionState = serde_json::from_str(r#"{"version":1,"defaultMode":"agent"}"#).unwrap();
|
||||
assert_eq!(agent.default_mode, Some(AiAssistantMode::Agent));
|
||||
@@ -5223,6 +5231,7 @@ mod tests {
|
||||
// camelCase key + lowercase value round-trip.
|
||||
let serialized = serde_json::to_value(agent).unwrap();
|
||||
assert_eq!(serialized["defaultMode"], serde_json::json!("agent"));
|
||||
assert_eq!(serialized["defaultAutoRouting"], serde_json::json!(false));
|
||||
}
|
||||
|
||||
struct CapturedJsonRequest {
|
||||
|
||||
@@ -7683,6 +7683,7 @@ mod tests {
|
||||
selection: AiEffortSelection::Enum("high".to_string()),
|
||||
}],
|
||||
default_mode: Some(AiAssistantMode::Agent),
|
||||
default_auto_routing: true,
|
||||
restore_last_conversation: true,
|
||||
default_templates_by_db_type: BTreeMap::from([("postgresql".to_string(), vec!["tpl-1".to_string()])]),
|
||||
last_used_templates_by_db_type: BTreeMap::from([("mysql".to_string(), vec!["tpl-2".to_string()])]),
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ test("AI composer exposes mode and action as one compact selector", () => {
|
||||
assert.match(footer, /selectModeActionItem\(button\.action\)/);
|
||||
assert.doesNotMatch(footer, /selectAction\(button\.action\)/);
|
||||
assert.match(footer, /<template v-if="showActionButtons">[\s\S]*?<div class="border-t my-1" \/>[\s\S]*?v-for="button in actionButtons"/);
|
||||
assert.match(source, /function selectModeActionItem\(action: AiAction\) \{\s*\/\/ Vector databases[\s\S]*?if \(!showActionButtons\.value\) return;/);
|
||||
assert.match(source, /function selectModeActionItem\(action: AiActionSelection\) \{\s*\/\/ Vector databases[\s\S]*?if \(!showActionButtons\.value\) return;/);
|
||||
});
|
||||
|
||||
test("AI effort control opens as a hoverable side submenu", () => {
|
||||
|
||||
Reference in New Issue
Block a user