From 21e3c9f4360ad6a9ca2ffa5f575de91c4e814bd6 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 30 Apr 2026 20:12:33 +0800 Subject: [PATCH] feat(ai): redesign AI assistant as right-side chat panel - Chat-style conversation UI with message bubbles - Multi-turn dialog with context history - SQL code blocks with "Apply" and "Copy" buttons - Toggle via Sparkles button in toolbar - Settings dialog preserved --- src/App.vue | 32 +-- src/components/editor/AiAssistant.vue | 317 +++++++++++--------------- src/i18n/locales/zh-CN.ts | 4 + src/lib/ai.ts | 9 +- 4 files changed, 165 insertions(+), 197 deletions(-) diff --git a/src/App.vue b/src/App.vue index 775335102c..5a1387a4a7 100644 --- a/src/App.vue +++ b/src/App.vue @@ -56,6 +56,7 @@ const { message: toastMessage, visible: toastVisible, toast } = useToast(); const showConnectionDialog = ref(false); const showHistory = ref(false); +const showAiPanel = ref(false); const showUpdateDialog = ref(false); const dangerSql = ref(""); const pendingDangerSql = ref(""); @@ -353,13 +354,6 @@ function replaceActiveSql(sql: string) { queryStore.updateSql(tab.id, sql); } -function appendActiveSql(sql: string) { - const tab = activeTab.value; - if (!tab) return; - const current = tab.sql.trimEnd(); - queryStore.updateSql(tab.id, current ? `${current}\n\n${sql}` : sql); -} - function changeActiveDatabase(database: any) { const tab = activeTab.value; if (!tab || typeof database !== "string") return; @@ -667,6 +661,15 @@ async function setupFileDrop() { {{ t('history.title') }} + + + + + + + AI + + @@ -895,12 +898,6 @@ async function setupFileDrop() { - {{ t('editor.pressToExecute') }} @@ -1044,6 +1041,15 @@ async function setupFileDrop() { + + + + -import { computed, ref } from "vue"; +import { nextTick, ref } from "vue"; import { useI18n } from "vue-i18n"; import { - Bot, - Check, - Clipboard, - Code2, - Copy, - FilePlus2, - Loader2, - MessageSquareText, - Replace, - Settings, - Sparkles, - Wand2, - Wrench, + Bot, Copy, Loader2, Replace, Send, Settings, + Sparkles, Trash2, X, } from "lucide-vue-next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useSettingsStore, type AiProvider } from "@/stores/settingsStore"; -import { buildAiContext, extractSql, runAiAction, type AiAction, type AiContext } from "@/lib/ai"; +import { buildAiContext, extractSql, runAiAction, type AiAction } from "@/lib/ai"; +import type { AiMessage } from "@/lib/tauri"; import type { ConnectionConfig, QueryTab } from "@/types/database"; const { t } = useI18n(); const settings = useSettingsStore(); +interface ChatMessage { + role: "user" | "assistant"; + content: string; + action?: AiAction; +} + const props = defineProps<{ - tab: QueryTab; + tab?: QueryTab; connection?: ConnectionConfig; }>(); const emit = defineEmits<{ replaceSql: [sql: string]; - appendSql: [sql: string]; + close: []; }>(); const action = ref("generate"); const prompt = ref(""); +const messages = ref([]); const isGenerating = ref(false); -const isBuildingContext = ref(false); const showSettings = ref(false); -const showPreview = ref(false); -const error = ref(""); -const output = ref(""); -const copied = ref(false); -const lastContext = ref(null); +const scrollRef = ref | null>(null); const tempProvider = ref(settings.aiConfig.provider); const tempApiKey = ref(settings.aiConfig.apiKey); @@ -74,26 +57,14 @@ const providerDefaults: Record custom: { endpoint: "", model: "" }, }; -const actionItems: Array<{ value: AiAction; labelKey: string; icon: any }> = [ - { value: "generate", labelKey: "ai.actions.generate", icon: Sparkles }, - { value: "explain", labelKey: "ai.actions.explain", icon: MessageSquareText }, - { value: "optimize", labelKey: "ai.actions.optimize", icon: Wand2 }, - { value: "fix", labelKey: "ai.actions.fix", icon: Wrench }, - { value: "convert", labelKey: "ai.actions.convert", icon: Replace }, - { value: "sampleData", labelKey: "ai.actions.sampleData", icon: FilePlus2 }, -]; - -const selectedAction = computed(() => actionItems.find((item) => item.value === action.value) ?? actionItems[0]); -const sqlCandidate = computed(() => extractSql(output.value)); -const canUseSql = computed(() => !!sqlCandidate.value.trim()); -const contextSummary = computed(() => { - if (!lastContext.value) return ""; - const tableCount = lastContext.value.tables.length; - return t("ai.contextSummary", { - database: lastContext.value.database, - tables: tableCount, - }); -}); +const actionLabels: Record = { + generate: "ai.actions.generate", + explain: "ai.actions.explain", + optimize: "ai.actions.optimize", + fix: "ai.actions.fix", + convert: "ai.actions.convert", + sampleData: "ai.actions.sampleData", +}; function openSettings() { tempProvider.value = settings.aiConfig.provider; @@ -119,169 +90,154 @@ function selectProvider(provider: AiProvider) { tempModel.value = providerDefaults[provider].model; } -async function generate() { - if (!props.connection) { - error.value = t("ai.noConnection"); - return; - } +function scrollToBottom() { + nextTick(() => { + const el = scrollRef.value?.$el?.querySelector("[data-radix-scroll-area-viewport]"); + if (el) el.scrollTop = el.scrollHeight; + }); +} + +async function send() { + const text = prompt.value.trim(); + if (!text || isGenerating.value) return; + + if (!props.connection || !props.tab) return; if (!settings.isConfigured()) { openSettings(); return; } - if (action.value === "generate" && !prompt.value.trim()) return; - if (action.value !== "generate" && !props.tab.sql.trim() && !prompt.value.trim()) { - error.value = t("ai.noSql"); - return; - } + + messages.value.push({ role: "user", content: text, action: action.value }); + prompt.value = ""; + scrollToBottom(); isGenerating.value = true; - isBuildingContext.value = true; - error.value = ""; - copied.value = false; try { const context = await buildAiContext(props.tab, props.connection); - lastContext.value = context; - isBuildingContext.value = false; - output.value = await runAiAction({ + const history: AiMessage[] = messages.value.slice(0, -1).map((m) => ({ + role: m.role, + content: m.content, + })); + const result = await runAiAction({ config: settings.aiConfig, action: action.value, - instruction: prompt.value, + instruction: text, context, - }); - showPreview.value = true; + }, history); + messages.value.push({ role: "assistant", content: result }); } catch (e: any) { - error.value = String(e.message || e); + messages.value.push({ role: "assistant", content: `Error: ${e.message || e}` }); } finally { - isBuildingContext.value = false; isGenerating.value = false; + scrollToBottom(); } } -function replaceSql() { - if (!canUseSql.value) return; - emit("replaceSql", sqlCandidate.value); - showPreview.value = false; +function applySql(text: string) { + const sql = extractSql(text); + if (sql) emit("replaceSql", sql); } -function appendSql() { - if (!canUseSql.value) return; - emit("appendSql", sqlCandidate.value); - showPreview.value = false; +async function copySql(text: string) { + const sql = extractSql(text); + if (sql) await navigator.clipboard.writeText(sql); } -async function copySql() { - if (!canUseSql.value) return; - await navigator.clipboard.writeText(sqlCandidate.value); - copied.value = true; - window.setTimeout(() => { copied.value = false; }, 1200); +function clearMessages() { + messages.value = []; } -async function copyAll() { - if (!output.value.trim()) return; - await navigator.clipboard.writeText(output.value.trim()); - copied.value = true; - window.setTimeout(() => { copied.value = false; }, 1200); +function hasSql(text: string): boolean { + return /```(?:sql|mysql|postgresql|sqlite|tsql|clickhouse)?\s*[\s\S]*?```/i.test(text); +} + +function formatMessageContent(text: string): string { + return text + .replace(/```(?:sql|mysql|postgresql|sqlite|tsql|clickhouse)?\s*([\s\S]*?)```/gi, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\n/g, ""); } - - - - action = v"> - - - - - - {{ t(item.labelKey) }} - - - - - - - - - - {{ isBuildingContext ? t('ai.readingSchema') : t('ai.run') }} + + + + AI + + - - - + + + + + - - {{ error }} - {{ contextSummary }} - - {{ t('ai.truncated') }} - - - - - - - - - - {{ t(selectedAction.labelKey) }} - - - - - - {{ contextSummary }} - {{ t('ai.truncated') }} + + + + + {{ t('ai.welcome') }} - - {{ output }} - - - - - {{ t('ai.sqlPreview') }} - - - - {{ copied ? t('ai.copied') : t('ai.copySql') }} - + + + + {{ msg.content }} + - - {{ sqlCandidate }} - + + + + + + + + + {{ t('ai.apply') }} + + + + {{ t('ai.copySql') }} + + + + + + + + {{ t('ai.thinking') }} + - - - - {{ t('ai.copyAll') }} + + + action = v"> + + + + + + {{ t(label) }} + + + + + + - - - {{ t('ai.append') }} - - - - {{ t('ai.replace') }} - - - - + + + @@ -312,9 +268,6 @@ async function copyAll() { Model - - {{ t('ai.settingsHint') }} - {{ t('grid.save') }} diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index ea21bf9cc7..c50ea7d8bd 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -138,6 +138,10 @@ export default { copied: "已复制", replace: "替换编辑器", append: "追加到编辑器", + apply: "应用到编辑器", + clear: "清空对话", + welcome: "输入问题,AI 帮你生成 SQL", + thinking: "思考中...", truncated: "上下文已截断", contextSummary: "{database} · {tables} 张表", settingsHint: "配置会保存在本机应用数据目录中。请求由 Tauri 后端发出,避免在前端直接暴露给模型服务。", diff --git a/src/lib/ai.ts b/src/lib/ai.ts index b7ee3c023e..6e36e245ab 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -38,7 +38,7 @@ const ACTION_INSTRUCTIONS: Record = { sampleData: "Generate safe sample SQL statements or mock data for the current schema. Do not use real production data.", }; -export async function runAiAction(input: AiRequestInput): Promise { +export async function runAiAction(input: AiRequestInput, history?: api.AiMessage[]): Promise { const systemPrompt = buildSystemPrompt(input.action, input.context); const userPrompt = [ `Action: ${input.action}`, @@ -48,10 +48,15 @@ export async function runAiAction(input: AiRequestInput): Promise { input.instruction.trim() || "(No extra instruction provided.)", ].join("\n"); + const messages: api.AiMessage[] = [ + ...(history || []), + { role: "user", content: userPrompt }, + ]; + return api.aiComplete({ config: input.config, systemPrompt, - messages: [{ role: "user", content: userPrompt }], + messages, maxTokens: 2400, temperature: 0.15, });
$1
{{ t('ai.welcome') }}
{{ output }}
{{ sqlCandidate }}
- {{ t('ai.settingsHint') }} -