feat(ai): add general action mode

This commit is contained in:
lexmin0412 authored and GitHub committed 2026-07-08 19:31:19 +08:00
1 parent dafa3e0f79
commit ac4b98c97c
13 files changed
+172 -27

No files matched your search

@@ -52,7 +52,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useQueryStore } from "@/stores/queryStore";
import { useToast } from "@/composables/useToast";
import { useNavigationTargets } from "@/composables/useNavigationTargets";
import { buildAiContext, runAgentStream, isVectorDbType, defaultActionForMode, isValidActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext } from "@/lib/ai/ai";
import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext } from "@/lib/ai/ai";
import { formatAiModelOption } from "@/lib/ai/aiModelPresentation";
import type { AgentEvent } from "@/lib/backend/tauri";
import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan";
@@ -132,7 +132,7 @@ const prompt = ref("");
const messages = ref<ChatMessage[]>([]);
const isGenerating = ref(false);
const scrollRef = ref<InstanceType<typeof ScrollArea> | null>(null);
const activeAction = ref<AiAction>("generate");
const activeAction = ref<AiAction>("general");
const assistantMode = ref<"ask" | "agent">("ask");
const currentSessionId = ref("");
const conversationId = ref("");
@@ -335,6 +335,16 @@ const selectedSqlFileMentions = ref<AiSqlFileMention[]>([]);
let mentionTimer: ReturnType<typeof setTimeout> | undefined;
let mentionRequestId = 0;
// Slash command menu
const commandOpen = ref(false);
const commandSelectedIndex = ref(0);
const commandStart = ref(0);
const filteredCommands = computed(() => {
const query = prompt.value.slice(commandStart.value + 1).toLowerCase();
return actionButtons.value.filter((cmd) => cmd.action.toLowerCase().includes(query) || t(cmd.key).toLowerCase().includes(query));
});
const AI_SQL_FILE_MENTION_CANDIDATE_LIMIT = 50;
const AI_SQL_FILE_CONTEXT_MAX_CHARS = 12_000;
@@ -347,6 +357,7 @@ interface AiActionButton {
/** Ask-mode actions: SQL-producing, never auto-run. */
const askActionButtons: AiActionButton[] = [
{ action: "general", icon: MessageSquarePlus, key: "ai.actions.general" },
{ action: "generate", icon: Wand2, key: "ai.actions.generate" },
{ action: "explain", icon: HelpCircle, key: "ai.actions.explain" },
{ action: "optimize", icon: Zap, key: "ai.actions.optimize" },
@@ -357,6 +368,7 @@ const askActionButtons: AiActionButton[] = [
/** Agent-mode actions: task-oriented, drive tool use and real results. */
const agentActionButtons: AiActionButton[] = [
{ action: "general", icon: MessageSquarePlus, key: "ai.actions.general" },
{ action: "query", icon: Search, key: "ai.actions.query" },
{ action: "exploreSchema", icon: Table2, key: "ai.actions.exploreSchema" },
{ action: "executeAndExplain", icon: Play, key: "ai.actions.executeAndExplain" },
@@ -366,18 +378,15 @@ const agentActionButtons: AiActionButton[] = [
const actionButtons = computed<AiActionButton[]>(() => (assistantMode.value === "agent" ? agentActionButtons : askActionButtons));
// Vector DBs hide the action menu and only expose collection tools (list_collections /
// browse_collection), never SQL tools. Keep their action at `generate` so the task contract
// doesn't tell the LLM to call execute_query / produce SQL — neither applies to vector stores.
// Vector DBs hide the action menu and only expose collection tools.
// Keep their action at `generate` so the task contract doesn't tell the LLM to call execute_query.
function resolveDefaultAction(mode: AiAssistantMode): AiAction {
if (props.connection && isVectorDbType(props.connection.db_type)) return "generate";
return defaultActionForMode(mode);
}
// Switching mode is a deliberate context change: land on that mode's default action so the
// menu and behavior match the new intent (Ask → generate, Agent → query). The shared
// `generate` action is not carried across because its label/semantics differ per mode
// ("生成 SQL" vs "生成但不执行").
// menu and behavior match the new intent. The shared `general` action is the default.
//
// `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
@@ -391,6 +400,18 @@ watch(assistantMode, (mode) => {
activeAction.value = resolveDefaultAction(mode);
});
watch(
() => props.connection?.db_type,
() => {
// Vector DBs hide the action picker, so keep the hidden action aligned with
// the collection-oriented prompt contract on initial render and connection changes.
if (props.connection && isVectorDbType(props.connection.db_type)) {
activeAction.value = "generate";
}
},
{ immediate: true },
);
function selectAction(action: AiAction) {
activeAction.value = action;
if (action === "fix" && props.tab?.result) {
@@ -1137,6 +1158,23 @@ function scrollMentionSelectedIntoView() {
function refreshMentionState() {
clearTimeout(mentionTimer);
// 优先检测斜杠命令(仅在输入内容为空时触发)
const textarea = promptTextareaRef.value;
const cursor = textarea?.selectionStart ?? prompt.value.length;
const beforeCursor = prompt.value.slice(0, cursor);
const slashMatch = /^\/([^\s]*)$/.exec(beforeCursor.trimStart());
if (slashMatch) {
mentionOpen.value = false;
commandOpen.value = true;
commandStart.value = beforeCursor.length - slashMatch[1].length - 1;
commandSelectedIndex.value = 0;
return;
}
commandOpen.value = false;
const mention = activeMentionAtCursor();
if (!mention || !props.connection || !props.tab?.database) {
mentionOpen.value = false;
@@ -1155,6 +1193,21 @@ function onPromptKeyup(event: KeyboardEvent) {
refreshMentionState();
}
function selectCommand(command: AiActionButton) {
const before = prompt.value.slice(0, commandStart.value);
const after = prompt.value.slice(promptTextareaRef.value?.selectionStart ?? prompt.value.length);
prompt.value = `${before}${after}`.replace(/\s{2,}/g, " ").trim();
commandOpen.value = false;
activeAction.value = command.action;
nextTick(() => {
const textarea = promptTextareaRef.value;
if (textarea) {
textarea.selectionStart = textarea.selectionEnd = before.length;
textarea.focus();
}
});
}
function insertMention(candidate: AiMentionCandidate) {
const textarea = promptTextareaRef.value;
const cursor = textarea?.selectionStart ?? prompt.value.length;
@@ -1173,6 +1226,30 @@ function insertMention(candidate: AiMentionCandidate) {
function onPromptKeydown(event: KeyboardEvent) {
if (isAiPromptImeCompositionEvent(event, promptCompositionActive.value)) return;
// 斜杠命令菜单键盘导航
if (commandOpen.value) {
if (event.key === "ArrowDown") {
event.preventDefault();
commandSelectedIndex.value = Math.min(commandSelectedIndex.value + 1, filteredCommands.value.length - 1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
commandSelectedIndex.value = Math.max(commandSelectedIndex.value - 1, 0);
return;
}
if ((event.key === "Enter" || event.key === "Tab") && filteredCommands.value[commandSelectedIndex.value]) {
event.preventDefault();
selectCommand(filteredCommands.value[commandSelectedIndex.value]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
commandOpen.value = false;
return;
}
}
if (mentionOpen.value) {
if (event.key === "ArrowDown") {
event.preventDefault();
@@ -1304,7 +1381,7 @@ async function send() {
await runAgentStream(
{
config: settings.aiConfig,
action: activeAction.value,
action: requestedAction,
mode: requestedMode,
instruction: modelInstruction,
context,
@@ -1373,7 +1450,6 @@ async function send() {
if (msg && requestedMode === "agent") msg.agentSteps = buildAiAgentStepItems(agentPlan);
if (agentPlan.handoffSql) emit("requestAutoExecuteSql", agentPlan.handoffSql);
}
activeAction.value = resolveDefaultAction(assistantMode.value);
currentSessionId.value = "";
// Apply deferred context compaction after streaming so assistantIdx stays stable.
// Visible chat history is kept for the user; future LLM history starts from this hidden summary.
@@ -1910,6 +1986,23 @@ async function openExternalUrl(url: string) {
</button>
</div>
</div>
<div v-if="commandOpen && filteredCommands.length" class="absolute bottom-full left-2 right-2 z-20 mb-1 max-h-56 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md">
<div class="max-h-56 overflow-auto p-1">
<button
v-for="(cmd, index) in filteredCommands"
:key="cmd.action"
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted"
:class="{ 'bg-muted': index === commandSelectedIndex }"
@mousedown.prevent="selectCommand(cmd)"
@mouseenter="commandSelectedIndex = index"
>
<component :is="cmd.icon" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="font-medium">/{{ cmd.action }}</span>
<span class="ml-auto text-[11px] text-muted-foreground">{{ t(cmd.key) }}</span>
</button>
</div>
</div>
<div v-if="promptMentionChips.length" class="mb-1.5 flex flex-wrap gap-1">
<button
v-for="mention in promptMentionChips"
+2
View File
@@ -1304,6 +1304,7 @@ export default {
reasoningLevelHigh: "High",
reasoningLevelHint: "Controls Codex CLI model_reasoning_effort. Default uses your Codex config.",
actions: {
general: "General",
generate: "Generate SQL",
explain: "Explain SQL",
optimize: "Optimize SQL",
@@ -1316,6 +1317,7 @@ export default {
generateNoExec: "Generate (no run)",
},
placeholders: {
general: "Ask me anything...",
generate: "Describe what you want to query, e.g. orders per user in the last 7 days",
explain: "Optional: add what you want to understand",
optimize: "Optional: add a goal, e.g. reduce full table scans",
+2
View File
@@ -1250,6 +1250,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: {
general: "General",
generate: "Generar SQL",
explain: "Explicar SQL",
optimize: "Optimizar SQL",
@@ -1262,6 +1263,7 @@ export default withEnglishFallback({
generateNoExec: "Generar (sin ejecutar)",
},
placeholders: {
general: "Pregunta lo que quieras...",
generate: "Describe lo que quieres consultar, p. ej. pedidos por usuario en los últimos 7 días",
explain: "Opcional: indica qué quieres entender",
optimize: "Opcional: indica un objetivo, p. ej. reducir los escaneos completos de tabla",
+2
View File
@@ -1248,6 +1248,7 @@ export default withEnglishFallback({
reasoningLevelHigh: "Alto",
reasoningLevelHint: "Controlla model_reasoning_effort di Codex CLI. Predefinito usa la configurazione Codex.",
actions: {
general: "Generale",
generate: "Genera SQL",
explain: "Spiega SQL",
optimize: "Ottimizza SQL",
@@ -1260,6 +1261,7 @@ export default withEnglishFallback({
generateNoExec: "Genera (senza eseguire)",
},
placeholders: {
general: "Chiedi qualsiasi cosa...",
generate: "Descrivi cosa desideri interrogare, es. ordini per utente negli ultimi 7 giorni",
explain: "Opzionale: aggiungi cosa desideri comprendere",
optimize: "Opzionale: aggiungi un obiettivo, es. ridurre le scansioni complete della tabella",
+2
View File
@@ -1248,6 +1248,7 @@ export default withEnglishFallback({
enableThinkingHint: "このオプションは/chat/completions APIとサポートされているモデルでのみ有効です。無効にするとトークン使用量を大幅に削減できますが、生成結果の品質が若干低下する可能性があります。",
anthropicMessagesHint: "Anthropic Messages 互換 API は通常 /v1/messages を使用します。",
actions: {
general: "一般的な質問",
generate: "SQLを生成",
explain: "SQLを説明",
optimize: "SQLを最適化",
@@ -1260,6 +1261,7 @@ export default withEnglishFallback({
generateNoExec: "生成のみ(実行しない)",
},
placeholders: {
general: "何でも聞いてください...",
generate: "クエリしたい内容を説明してください(例: 過去7日間のユーザーごとの注文数)",
explain: "任意: 理解したい内容を追加",
optimize: "任意: 目標を追加(例: フルテーブルスキャンを減らす)",
+2
View File
@@ -1249,6 +1249,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: {
general: "Geral",
generate: "Gerar SQL",
explain: "Explicar SQL",
optimize: "Otimizar SQL",
@@ -1261,6 +1262,7 @@ export default withEnglishFallback({
generateNoExec: "Gerar (sem executar)",
},
placeholders: {
general: "Pergunte qualquer coisa...",
generate: "Descreva o que você quer consultar, por exemplo, pedidos por usuário nos últimos 7 dias",
explain: "Opcional: adicione o que você quer entender",
optimize: "Opcional: adicione um objetivo, por exemplo, reduzir varreduras completas de tabela",
+2
View File
@@ -1306,6 +1306,7 @@ export default withEnglishFallback({
reasoningLevelHigh: "高",
reasoningLevelHint: "控制 Codex CLI 的 model_reasoning_effort。默认使用你的 Codex 配置。",
actions: {
general: "通用问答",
generate: "生成 SQL",
explain: "解释 SQL",
optimize: "优化 SQL",
@@ -1318,6 +1319,7 @@ export default withEnglishFallback({
generateNoExec: "生成但不执行",
},
placeholders: {
general: "问我任何问题...",
generate: "描述你想查询什么,例如:统计最近 7 天每个用户的订单数",
explain: "可留空,或补充你关心的点",
optimize: "可留空,或说明优化目标,例如:减少全表扫描",
+2
View File
@@ -1249,6 +1249,7 @@ export default withEnglishFallback({
reasoningLevelHigh: "高",
reasoningLevelHint: "控制 Codex CLI 的 model_reasoning_effort。預設會使用你的 Codex 設定。",
actions: {
general: "通用問答",
generate: "產生 SQL",
explain: "解釋 SQL",
optimize: "最佳化 SQL",
@@ -1261,6 +1262,7 @@ export default withEnglishFallback({
generateNoExec: "產生但不執行",
},
placeholders: {
general: "問我任何問題...",
generate: "描述你想查詢什麼,例如:統計最近 7 天每個使用者的訂單數",
explain: "可留空,或補充你關心的點",
optimize: "可留空,或說明最佳化目標,例如:減少全資料表掃描",
@@ -3,13 +3,12 @@ import { ASK_ACTIONS, AGENT_ACTIONS, defaultActionForMode, isValidActionForMode
describe("AI action mode mapping", () => {
describe("defaultActionForMode", () => {
it("defaults Ask to generate", () => {
expect(defaultActionForMode("ask")).toBe("generate");
it("defaults Ask to general", () => {
expect(defaultActionForMode("ask")).toBe("general");
});
it("defaults Agent to query (not generate)", () => {
// The whole point of the feature: Agent mode must not default to SQL generation.
expect(defaultActionForMode("agent")).toBe("query");
it("defaults Agent to general", () => {
expect(defaultActionForMode("agent")).toBe("general");
});
});
@@ -47,15 +46,15 @@ describe("AI action mode mapping", () => {
});
describe("action sets", () => {
it("Ask menu keeps the SQL-producing actions", () => {
expect(ASK_ACTIONS).toEqual(["generate", "explain", "optimize", "fix", "convert", "sampleData"]);
it("Ask menu starts with general, then SQL-producing actions", () => {
expect(ASK_ACTIONS).toEqual(["general", "generate", "explain", "optimize", "fix", "convert", "sampleData"]);
});
it("Agent menu is task-oriented, starts with query, and still offers generate", () => {
expect(AGENT_ACTIONS[0]).toBe("query");
// generate is shared so users can still request SQL-only output ("生成但不执行").
it("Agent menu starts with general, then task-oriented actions", () => {
expect(AGENT_ACTIONS[0]).toBe("general");
// generate is shared so users can still request SQL-only output.
expect(AGENT_ACTIONS).toContain("generate");
expect(AGENT_ACTIONS).toEqual(["query", "exploreSchema", "executeAndExplain", "generate"]);
expect(AGENT_ACTIONS).toEqual(["general", "query", "exploreSchema", "executeAndExplain", "generate"]);
});
});
});
+5 -5
View File
@@ -32,20 +32,20 @@ function dbLabel(dbType: DatabaseType): string {
return labels[dbType] || dbType;
}
export type AiAction = "generate" | "explain" | "optimize" | "fix" | "convert" | "sampleData" | "query" | "exploreSchema" | "executeAndExplain";
export type AiAction = "general" | "generate" | "explain" | "optimize" | "fix" | "convert" | "sampleData" | "query" | "exploreSchema" | "executeAndExplain";
export type AiAssistantMode = "ask" | "agent";
/** Actions shown in the Ask mode menu: SQL-producing, never auto-run. */
export const ASK_ACTIONS: AiAction[] = ["generate", "explain", "optimize", "fix", "convert", "sampleData"];
export const ASK_ACTIONS: AiAction[] = ["general", "generate", "explain", "optimize", "fix", "convert", "sampleData"];
/**
* Actions shown in the Agent mode menu: task-oriented, drive tool use.
* `generate` is shared with Ask so users can still request SQL-only output without execution.
*/
export const AGENT_ACTIONS: AiAction[] = ["query", "exploreSchema", "executeAndExplain", "generate"];
export const AGENT_ACTIONS: AiAction[] = ["general", "query", "exploreSchema", "executeAndExplain", "generate"];
export function defaultActionForMode(mode: AiAssistantMode): AiAction {
return mode === "agent" ? "query" : "generate";
export function defaultActionForMode(_mode: AiAssistantMode): AiAction {
return "general";
}
export function isValidActionForMode(action: AiAction, mode: AiAssistantMode): boolean {
+22
View File
@@ -25,6 +25,28 @@ export interface AiSkillDefinition {
}
export const AI_SKILL_DEFINITIONS: AiSkillDefinition[] = [
{
id: "general",
action: "general",
title: {
en: "General",
zh: "通用问答",
},
riskPolicy: "readonly",
contextNeeds: [],
userInstruction: {
en: "Answer the user's question directly and naturally. Use your general knowledge and the database schema context when relevant.",
zh: "直接、自然地回答用户的问题。使用你的通用知识,涉及数据库时可参考 Schema 上下文。",
},
systemRules: {
en: ["Answer naturally and helpfully. Adapt to the user's intent — whether that's a greeting, a conceptual question, or a database-related inquiry."],
zh: ["自然、有帮助地回答。根据用户意图灵活应对——无论是问候、概念性问题还是数据库相关咨询。"],
},
outputContract: {
en: ["Provide a clear, helpful answer adapted to the user's question."],
zh: ["根据用户问题提供清晰、有帮助的回答。"],
},
},
{
id: "generate_sql",
action: "generate",