feat(ai): add general action mode

This commit is contained in:
lexmin0412 2026-07-08 19:31:19 +08:00 committed by GitHub
parent dafa3e0f79
commit ac4b98c97c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 172 additions and 27 deletions

View File

@ -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"

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",

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",

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",

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: "任意: 目標を追加(例: フルテーブルスキャンを減らす)",

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",

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: "可留空,或说明优化目标,例如:减少全表扫描",

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: "可留空,或說明最佳化目標,例如:減少全資料表掃描",

View File

@ -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"]);
});
});
});

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 {

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",

View File

@ -479,6 +479,7 @@ fn augment_system_prompt_with_task_contract(
"This is a SQL-producing action: produce the final SQL in a fenced ```sql code block. Use tools only as intermediate evidence for schema/dialect; do not stop at a tool-result summary. In Agent mode, execute a query only when the original request explicitly asks for real data/results, not when it merely asks to generate SQL."
} else {
match action.to_ascii_lowercase().as_str() {
"general" => "This is a general Q&A mode. Answer the user's question directly and naturally using your knowledge and any available database context. Adapt to the user's intent.",
"query" => "This is a data-query task: call execute_query to obtain real results, then answer based on the actual data. Do not stop after merely outputting SQL text.",
"exploreschema" => "This is a schema-inspection task: use list_tables/get_columns to obtain authoritative structure, then summarize. Do not execute data queries unless the user explicitly asks for data.",
"executeandexplain" => "This is an execute-and-explain task: call execute_query to run the current SQL, then explain the real results.",
@ -530,6 +531,7 @@ fn build_contract_repair_prompt(task_contract: Option<&AiTaskContract>, is_agent
"For this SQL-producing action, produce SQL in a fenced ```sql code block. Tool results are evidence only; do not answer by summarizing schema/tool output. Execute a query only when the original request explicitly asks for real data/results."
} else {
match action.to_ascii_lowercase().as_str() {
"general" => "For this general Q&A, answer the user's question directly and naturally.",
"query" => "For this data-query task, call execute_query and answer based on real data; do not stop at SQL text or a schema summary.",
"exploreschema" => "For this schema-inspection task, summarize real structure from list_tables/get_columns; do not invent columns.",
"executeandexplain" => "For this execute-and-explain task, run the current SQL via execute_query and explain the real results.",
@ -1375,4 +1377,15 @@ mod tests {
assert!(repair.contains("data-query task"));
assert!(repair.contains("call execute_query"));
}
#[test]
fn general_action_skips_sql_validation() {
let contract = AiTaskContract {
action: Some("general".to_string()),
mode: Some("ask".to_string()),
user_request: Some("你好".to_string()),
};
let answer = "你好!我是 DBX 的数据库助手。有什么可以帮你的吗?";
assert_eq!(validate_final_answer(Some(&contract), answer), FinalAnswerCheck::Satisfied);
}
}

View File

@ -4,6 +4,7 @@ import type { AiAction } from "../../apps/desktop/src/lib/ai/ai.ts";
import { AI_SKILL_DEFINITIONS, aiSkillForAction } from "../../apps/desktop/src/lib/ai/aiSkills.ts";
const actions: AiAction[] = [
"general",
"generate",
"explain",
"optimize",
@ -25,7 +26,10 @@ test("defines one internal AI skill per assistant action", () => {
assert.match(skill.id, /^[a-z][a-z0-9_]*$/);
assert.ok(skill.title.zh);
assert.ok(skill.title.en);
assert.ok(skill.contextNeeds.length > 0);
// general skill has empty contextNeeds, others must have at least one
if (action !== "general") {
assert.ok(skill.contextNeeds.length > 0);
}
assert.ok(skill.systemRules.zh.length > 0);
assert.ok(skill.systemRules.en.length > 0);
assert.ok(skill.userInstruction.zh.length > 0);