feat: add AI streaming functionality and enhance AI assistant features

This commit is contained in:
t8y2
2026-04-30 21:25:01 +08:00
parent 626c9c6049
commit 9e7ed096b7
9 changed files with 541 additions and 137 deletions
+96 -79
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { useI18n } from "vue-i18n";
import { DatabaseZap, FilePlus2, Play, Loader2, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Sparkles, Pin, AlignLeft, CloudDownload } from "lucide-vue-next";
import { DatabaseZap, FilePlus2, Play, Loader2, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Bot, Pin, AlignLeft, CloudDownload } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@@ -56,7 +56,22 @@ const { message: toastMessage, visible: toastVisible, toast } = useToast();
const showConnectionDialog = ref(false);
const showHistory = ref(false);
const showAiPanel = ref(false);
const showAiPanel = ref(localStorage.getItem("dbx-ai-panel-open") !== "false");
const aiPanelSize = ref(Number(localStorage.getItem("dbx-ai-panel-size")) || 25);
function toggleAiPanel() {
showAiPanel.value = !showAiPanel.value;
localStorage.setItem("dbx-ai-panel-open", String(showAiPanel.value));
}
function onAiPanelResized(panes: { size: number }[]) {
const aiPane = panes[panes.length - 1];
if (aiPane) {
aiPanelSize.value = aiPane.size;
localStorage.setItem("dbx-ai-panel-size", String(aiPane.size));
}
}
const showUpdateDialog = ref(false);
const dangerSql = ref("");
const pendingDangerSql = ref("");
@@ -275,11 +290,11 @@ function onFormatSqlError() {
}
function newQuery() {
if (!connectionStore.activeConnectionId) return;
const conn = connectionStore.connections.find(
(c) => c.id === connectionStore.activeConnectionId
);
const connId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id;
if (!connId) return;
const conn = connectionStore.getConfig(connId);
if (!conn) return;
connectionStore.activeConnectionId = connId;
queryStore.createTab(conn.id, conn.database || "");
}
@@ -633,7 +648,7 @@ async function setupFileDrop() {
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="newQuery" :disabled="!connectionStore.activeConnectionId">
<Button variant="ghost" size="icon" class="h-7 w-7" @click="newQuery" :disabled="!connectionStore.connections.length">
<FilePlus2 class="h-4 w-4" />
</Button>
</TooltipTrigger>
@@ -663,8 +678,8 @@ async function setupFileDrop() {
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" :class="{ 'bg-accent': showAiPanel }" @click="showAiPanel = !showAiPanel">
<Sparkles class="h-4 w-4" />
<Button variant="ghost" size="icon" class="h-7 w-7" :class="{ 'bg-accent': showAiPanel }" @click="toggleAiPanel">
<Bot class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>AI</TooltipContent>
@@ -701,11 +716,11 @@ async function setupFileDrop() {
<!-- Main Content -->
<div class="flex-1 flex min-h-0">
<Splitpanes class="flex-1 min-h-0 min-w-0">
<Splitpanes class="flex-1 min-h-0 min-w-0" @resized="onAiPanelResized">
<!-- Sidebar -->
<Pane :size="20" :min-size="10" :max-size="40">
<div class="h-full flex flex-col overflow-hidden">
<div class="h-8 flex items-center px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20">
<div class="h-9 flex items-center px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20">
{{ t('sidebar.connections') }}
<span class="flex-1" />
<Tooltip>
@@ -735,7 +750,7 @@ async function setupFileDrop() {
<Pane :size="80">
<div class="h-full flex flex-col min-w-0">
<!-- Tabs Bar -->
<div v-if="queryStore.tabs.length > 0" class="h-8 flex items-center border-b bg-muted/20 overflow-x-auto shrink-0">
<div v-if="queryStore.tabs.length > 0" class="h-9 flex items-center border-b bg-muted/20 overflow-x-auto shrink-0">
<ContextMenu
v-for="tab in queryStore.tabs"
:key="tab.id"
@@ -796,62 +811,7 @@ async function setupFileDrop() {
<!-- Editor Panel -->
<div v-if="activeTab" class="flex flex-col flex-1 min-h-0">
<div v-if="activeTab.mode === 'query' && !isPreviewTab(activeTab)" class="h-8 shrink-0 border-b bg-background/80 px-3 flex items-center gap-2 text-xs text-muted-foreground">
<Server class="h-3.5 w-3.5 shrink-0" />
<span v-if="activeConnection?.color" class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: activeConnection.color }" />
<Select
:model-value="activeConnectionValue"
@update:model-value="changeActiveConnection"
>
<SelectTrigger class="h-6 w-auto max-w-48 border-0 bg-transparent px-1 text-xs font-medium text-foreground shadow-none focus:ring-0">
<SelectValue :placeholder="t('editor.selectConnection')">
{{ connectionDisplayName(activeConnectionValue) }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="connection in connectionStore.connections"
:key="connection.id"
:value="connection.id"
>
<div class="flex items-center gap-2">
<span v-if="connection.color" class="h-3.5 w-1 rounded-full shrink-0" :style="{ backgroundColor: connection.color }" />
<span>{{ connection.name }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<span class="text-muted-foreground/50">/</span>
<span class="shrink-0">{{ connectionDriverLabel(activeConnection) }}</span>
<span class="text-muted-foreground/50">/</span>
<Select
:model-value="activeDatabaseValue"
@update:model-value="changeActiveDatabase"
@update:open="(open: boolean) => { if (open && activeConnection) loadDatabaseOptions(activeConnection.id).catch(() => {}) }"
>
<SelectTrigger class="h-6 w-auto max-w-56 border-0 bg-transparent px-1 text-xs shadow-none focus:ring-0">
<SelectValue :placeholder="loadingDatabaseOptions[activeConnection?.id || ''] ? t('common.loading') : t('editor.selectDatabase')">
{{ databaseDisplayName(activeDatabaseValue) }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="database in activeDatabaseOptions"
:key="database"
:value="database"
>
{{ databaseDisplayName(database) }}
</SelectItem>
<SelectItem v-if="!activeDatabaseOptions.length && activeDatabaseValue" :value="activeDatabaseValue">
{{ databaseDisplayName(activeDatabaseValue) }}
</SelectItem>
</SelectContent>
</Select>
<template v-for="item in activeTabContext.slice(2)" :key="item">
<span class="text-muted-foreground/50">/</span>
<span class="min-w-0 truncate">{{ item }}</span>
</template>
<span class="flex-1" />
<div v-if="activeTab.mode === 'query' && !isPreviewTab(activeTab)" class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-1 text-xs text-muted-foreground">
<div class="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger as-child>
@@ -871,7 +831,62 @@ async function setupFileDrop() {
<TooltipContent>{{ t('toolbar.formatSql') }}</TooltipContent>
</Tooltip>
</div>
<div v-if="activeTab.tableMeta" class="flex min-w-0 items-center gap-1">
<span class="flex-1" />
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<span v-if="activeConnection?.color" class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: activeConnection.color }" />
<Server class="h-3.5 w-3.5 shrink-0" />
<Select
:model-value="activeConnectionValue"
@update:model-value="changeActiveConnection"
>
<SelectTrigger class="h-6 w-auto max-w-48 border-0 bg-transparent px-1 text-xs font-medium text-foreground shadow-none focus:ring-0">
<SelectValue :placeholder="t('editor.selectConnection')">
{{ connectionDisplayName(activeConnectionValue) }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="connection in connectionStore.connections"
:key="connection.id"
:value="connection.id"
>
<div class="flex items-center gap-2">
<span v-if="connection.color" class="h-3.5 w-1 rounded-full shrink-0" :style="{ backgroundColor: connection.color }" />
<span>{{ connection.name }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center gap-1">
<Database class="h-3.5 w-3.5 shrink-0" />
<Select
:model-value="activeDatabaseValue"
@update:model-value="changeActiveDatabase"
@update:open="(open: boolean) => { if (open && activeConnection) loadDatabaseOptions(activeConnection.id).catch(() => {}) }"
>
<SelectTrigger class="h-6 w-auto max-w-56 border-0 bg-transparent px-1 text-xs shadow-none focus:ring-0">
<SelectValue :placeholder="loadingDatabaseOptions[activeConnection?.id || ''] ? t('common.loading') : t('editor.selectDatabase')">
{{ databaseDisplayName(activeDatabaseValue) }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="database in activeDatabaseOptions"
:key="database"
:value="database"
>
{{ databaseDisplayName(database) }}
</SelectItem>
<SelectItem v-if="!activeDatabaseOptions.length && activeDatabaseValue" :value="activeDatabaseValue">
{{ databaseDisplayName(activeDatabaseValue) }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div v-if="activeTab.tableMeta" class="flex min-w-0 items-center gap-1 ml-2">
<Table2 class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ activeTab.tableMeta.columns.length }} {{ t('tree.columns') }}</span>
</div>
@@ -1015,7 +1030,7 @@ async function setupFileDrop() {
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="showConnectionDialog = true">
<Plus class="h-4 w-4" /> {{ t('toolbar.newConnection') }}
</button>
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" :disabled="!connectionStore.activeConnectionId" @click="newQuery">
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" :disabled="!connectionStore.connections.length" @click="newQuery">
<FilePlus2 class="h-4 w-4" /> {{ t('toolbar.newQuery') }}
</button>
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="showHistory = true">
@@ -1035,21 +1050,23 @@ async function setupFileDrop() {
</div>
</div>
</Pane>
<Pane v-if="showAiPanel" :size="aiPanelSize" :min-size="15" :max-size="40">
<div class="h-full bg-background">
<AiAssistant
:tab="activeTab"
:connection="activeConnection"
@replace-sql="replaceActiveSql"
@close="toggleAiPanel"
/>
</div>
</Pane>
</Splitpanes>
<!-- History Panel (fixed width, outside Splitpanes) -->
<div v-if="showHistory" class="w-72 h-full shrink-0 border-l bg-background">
<QueryHistory @restore="onHistoryRestore" @close="showHistory = false" />
</div>
<div v-if="showAiPanel" class="w-80 h-full shrink-0 border-l bg-background">
<AiAssistant
:tab="activeTab"
:connection="activeConnection"
@replace-sql="replaceActiveSql"
@close="showAiPanel = false"
/>
</div>
</div>
<ConnectionDialog
+167 -53
View File
@@ -1,27 +1,32 @@
<script setup lang="ts">
import { nextTick, ref } from "vue";
import { computed, nextTick, ref } from "vue";
import { useI18n } from "vue-i18n";
import {
Bot, Copy, Loader2, Replace, Send, Settings,
Sparkles, Trash2, X,
ArrowUp, Bot, Check, Copy, Database, Loader2, Replace, Server, Settings,
Trash2, X,
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useSettingsStore, type AiProvider } from "@/stores/settingsStore";
import { buildAiContext, extractSql, runAiAction } from "@/lib/ai";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { buildAiContext, runAiStream } from "@/lib/ai";
import { listDatabases, redisListDatabases, mongoListDatabases } from "@/lib/tauri";
import type { AiMessage } from "@/lib/tauri";
import type { ConnectionConfig, QueryTab } from "@/types/database";
const { t } = useI18n();
const settings = useSettingsStore();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
interface ChatMessage {
role: "user" | "assistant";
@@ -44,6 +49,49 @@ const isGenerating = ref(false);
const showSettings = ref(false);
const scrollRef = ref<InstanceType<typeof ScrollArea> | null>(null);
const chatTitle = computed(() => {
const first = messages.value.find((m) => m.role === "user");
return first ? first.content.slice(0, 30) : t("ai.newChat");
});
const databaseOptions = ref<string[]>([]);
async function loadDatabases() {
if (!props.connection) return;
try {
if (props.connection.db_type === "redis") {
const dbs = await redisListDatabases(props.connection.id);
databaseOptions.value = dbs.map(String);
} else if (props.connection.db_type === "mongodb") {
databaseOptions.value = await mongoListDatabases(props.connection.id);
} else {
const list = await listDatabases(props.connection.id);
databaseOptions.value = list.map((d: { name: string }) => d.name);
}
} catch {
databaseOptions.value = [];
}
}
function changeConnection(connectionId: string) {
const conn = connectionStore.getConfig(connectionId);
if (!conn) return;
connectionStore.activeConnectionId = connectionId;
const tab = props.tab;
if (tab) {
queryStore.updateConnection(tab.id, connectionId, conn.database || "");
} else {
queryStore.createTab(connectionId, conn.database || "");
}
}
function changeDatabase(database: string) {
const tab = props.tab;
if (!tab) return;
queryStore.updateDatabase(tab.id, database);
}
const tempProvider = ref<AiProvider>(settings.aiConfig.provider);
const tempApiKey = ref(settings.aiConfig.apiKey);
const tempEndpoint = ref(settings.aiConfig.endpoint);
@@ -101,58 +149,83 @@ async function send() {
scrollToBottom();
isGenerating.value = true;
messages.value.push({ role: "assistant", content: "" });
const assistantIdx = messages.value.length - 1;
try {
const context = await buildAiContext(props.tab, props.connection);
const history: AiMessage[] = messages.value.slice(0, -1).map((m) => ({
const history: AiMessage[] = messages.value.slice(0, -2).map((m) => ({
role: m.role,
content: m.content,
}));
const result = await runAiAction({
await runAiStream({
config: settings.aiConfig,
action: "generate",
instruction: text,
context,
}, history);
messages.value.push({ role: "assistant", content: result });
}, history, (delta) => {
messages.value[assistantIdx].content += delta;
scrollToBottom();
});
} catch (e: any) {
messages.value.push({ role: "assistant", content: `Error: ${e.message || e}` });
messages.value[assistantIdx].content = `Error: ${e.message || e}`;
} finally {
isGenerating.value = false;
scrollToBottom();
}
}
function applySql(text: string) {
const sql = extractSql(text);
if (sql) emit("replaceSql", sql);
function applySql(code: string) {
emit("replaceSql", code);
}
async function copySql(text: string) {
const sql = extractSql(text);
if (sql) await navigator.clipboard.writeText(sql);
const copiedIndex = ref("");
async function copyCode(code: string, key: string) {
await navigator.clipboard.writeText(code);
copiedIndex.value = key;
setTimeout(() => { if (copiedIndex.value === key) copiedIndex.value = ""; }, 2000);
}
function clearMessages() {
messages.value = [];
}
function hasSql(text: string): boolean {
return /```(?:sql|mysql|postgresql|sqlite|tsql|clickhouse)?\s*[\s\S]*?```/i.test(text);
interface MessageSegment {
type: "text" | "code";
content: string;
lang?: string;
}
function formatMessageContent(text: string): string {
function parseMessage(text: string): MessageSegment[] {
const segments: MessageSegment[] = [];
const regex = /```(sql|mysql|postgresql|sqlite|tsql|clickhouse)?\s*([\s\S]*?)```/gi;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({ type: "text", content: text.slice(lastIndex, match.index) });
}
segments.push({ type: "code", lang: (match[1] || "sql").toUpperCase(), content: match[2].trim() });
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
segments.push({ type: "text", content: text.slice(lastIndex) });
}
return segments;
}
function formatInlineText(text: string): string {
return text
.replace(/```(?:sql|mysql|postgresql|sqlite|tsql|clickhouse)?\s*([\s\S]*?)```/gi, '<pre class="my-2 rounded bg-black/30 p-2 text-xs overflow-x-auto"><code>$1</code></pre>')
.replace(/`([^`]+)`/g, '<code class="rounded bg-black/20 px-1 py-0.5 text-xs">$1</code>')
.replace(/`([^`]+)`/g, '<code class="rounded bg-muted px-1.5 py-0.5 text-[11px] font-mono">$1</code>')
.replace(/\n/g, "<br>");
}
</script>
<template>
<div class="flex h-full flex-col">
<div class="flex items-center gap-2 border-b px-3 py-2">
<Sparkles class="h-4 w-4 text-primary shrink-0" />
<span class="text-sm font-medium flex-1">AI</span>
<div class="h-9 flex items-center gap-2 border-b px-3 shrink-0">
<Bot class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="flex-1 truncate text-xs font-medium">{{ chatTitle }}</span>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="clearMessages" :title="t('ai.clear')">
<Trash2 class="h-3.5 w-3.5" />
</Button>
@@ -164,12 +237,12 @@ function formatMessageContent(text: string): string {
</Button>
</div>
<ScrollArea ref="scrollRef" class="flex-1">
<div v-if="messages.length === 0" class="flex-1 flex flex-col items-center justify-center text-center text-muted-foreground">
<Bot class="h-10 w-10 mb-3 opacity-30" />
<p class="text-sm">{{ t('ai.welcome') }}</p>
</div>
<ScrollArea v-else ref="scrollRef" class="flex-1">
<div class="flex flex-col gap-3 p-3">
<div v-if="messages.length === 0" class="flex flex-col items-center justify-center py-12 text-center text-muted-foreground">
<Bot class="h-10 w-10 mb-3 opacity-30" />
<p class="text-sm">{{ t('ai.welcome') }}</p>
</div>
<template v-for="(msg, i) in messages" :key="i">
<div v-if="msg.role === 'user'" class="flex justify-end">
@@ -179,18 +252,27 @@ function formatMessageContent(text: string): string {
</div>
<div v-else class="flex flex-col gap-1">
<div class="max-w-[95%] rounded-lg bg-muted px-3 py-2 text-xs leading-relaxed">
<div v-html="formatMessageContent(msg.content)" />
</div>
<div v-if="hasSql(msg.content)" class="flex gap-1">
<Button variant="outline" size="xs" class="h-6 text-[10px]" @click="applySql(msg.content)">
<Replace class="h-3 w-3" />
{{ t('ai.apply') }}
</Button>
<Button variant="ghost" size="xs" class="h-6 text-[10px]" @click="copySql(msg.content)">
<Copy class="h-3 w-3" />
{{ t('ai.copySql') }}
</Button>
<div class="max-w-[95%] text-xs leading-relaxed">
<template v-for="(seg, j) in parseMessage(msg.content)" :key="j">
<div v-if="seg.type === 'text'" class="rounded-lg bg-muted px-3 py-2">
<span v-html="formatInlineText(seg.content)" />
</div>
<div v-else class="my-1 rounded-md overflow-hidden bg-zinc-900 dark:bg-zinc-900">
<div class="flex items-center px-3 py-1.5 text-[10px] text-zinc-400 font-medium border-b border-zinc-700/50">
<Database class="h-3 w-3 mr-1.5" />
<span>{{ seg.lang }}</span>
<span class="flex-1" />
<button class="p-0.5 rounded hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200" :title="t('ai.apply')" @click="applySql(seg.content)">
<Replace class="h-3.5 w-3.5" />
</button>
<button class="p-0.5 rounded hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 ml-1" :title="copiedIndex === `${i}-${j}` ? t('ai.copied') : t('ai.copySql')" @click="copyCode(seg.content, `${i}-${j}`)">
<Check v-if="copiedIndex === `${i}-${j}`" class="h-3.5 w-3.5 text-green-400" />
<Copy v-else class="h-3.5 w-3.5" />
</button>
</div>
<pre class="p-3 text-xs leading-relaxed overflow-x-auto text-zinc-100"><code>{{ seg.content }}</code></pre>
</div>
</template>
</div>
</div>
</template>
@@ -202,18 +284,50 @@ function formatMessageContent(text: string): string {
</div>
</ScrollArea>
<div class="border-t p-2">
<div class="flex items-center gap-1.5">
<Input
v-model="prompt"
class="h-7 flex-1 text-xs"
:placeholder="t('ai.placeholder')"
:disabled="isGenerating"
@keydown.enter="send"
/>
<Button variant="default" size="icon" class="h-7 w-7 shrink-0" :disabled="isGenerating || !prompt.trim()" @click="send">
<Send class="h-3.5 w-3.5" />
</Button>
<div class="p-2">
<div class="rounded-lg border bg-background px-2 pb-2 pt-1">
<div v-if="connectionStore.connections.length" class="flex items-center gap-1 mb-1 text-xs text-foreground/80">
<Server class="h-3 w-3 shrink-0" />
<Select :model-value="connection?.id || ''" @update:model-value="(v: any) => changeConnection(v)">
<SelectTrigger class="h-5 w-auto border-0 rounded-none bg-transparent p-0 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3">
<SelectValue :placeholder="t('editor.selectConnection')">{{ connection?.name || t('editor.selectConnection') }}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="conn in connectionStore.connections" :key="conn.id" :value="conn.id">
{{ conn.name }}
</SelectItem>
</SelectContent>
</Select>
<template v-if="connection">
<span class="text-foreground/25">/</span>
<Select :model-value="tab?.database || ''" @update:model-value="(v: any) => changeDatabase(v)" @update:open="(open: boolean) => { if (open) loadDatabases() }">
<SelectTrigger class="h-5 w-auto border-0 rounded-none bg-transparent p-0 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3">
<SelectValue :placeholder="t('editor.selectDatabase')">{{ tab?.database || t('editor.selectDatabase') }}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="db in databaseOptions" :key="db" :value="db">{{ db }}</SelectItem>
<SelectItem v-if="!databaseOptions.length && tab?.database" :value="tab.database">{{ tab.database }}</SelectItem>
</SelectContent>
</Select>
</template>
</div>
<div class="flex items-end gap-1.5">
<textarea
v-model="prompt"
rows="4"
class="flex-1 resize-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('ai.placeholder')"
:disabled="isGenerating"
@keydown.enter.exact="send"
/>
<button
class="h-7 w-7 shrink-0 rounded-full bg-foreground text-background flex items-center justify-center disabled:opacity-30"
:disabled="isGenerating || !prompt.trim()"
@click="send"
>
<ArrowUp class="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
+3 -2
View File
@@ -125,7 +125,7 @@ export default {
loading: "加载中...",
},
ai: {
placeholder: "用自然语言描述你的查询...",
placeholder: "描述你想查什么...",
settings: "AI 设置",
provider: "提供商",
run: "执行",
@@ -140,7 +140,8 @@ export default {
append: "追加到编辑器",
apply: "应用到编辑器",
clear: "清空对话",
welcome: "输入问题,AI 帮你生成 SQL",
welcome: "聊聊你想查什么,我来写 SQL",
newChat: "新对话",
thinking: "思考中...",
truncated: "上下文已截断",
contextSummary: "{database} · {tables} 张表",
+32
View File
@@ -62,6 +62,38 @@ export async function runAiAction(input: AiRequestInput, history?: api.AiMessage
});
}
export async function runAiStream(
input: AiRequestInput,
history: api.AiMessage[] | undefined,
onDelta: (delta: string) => void,
): Promise<void> {
const systemPrompt = buildSystemPrompt(input.action, input.context);
const userPrompt = [
`Action: ${input.action}`,
ACTION_INSTRUCTIONS[input.action],
"",
"User request:",
input.instruction.trim() || "(No extra instruction provided.)",
].join("\n");
const messages: api.AiMessage[] = [
...(history || []),
{ role: "user", content: userPrompt },
];
const sessionId = crypto.randomUUID();
await api.aiStream(sessionId, {
config: input.config,
systemPrompt,
messages,
maxTokens: 2400,
temperature: 0.15,
}, (chunk) => {
if (!chunk.done && chunk.delta) onDelta(chunk.delta);
});
}
export function extractSql(text: string): string {
const fenced = text.match(/```(?:sql|mysql|postgresql|sqlite|tsql|clickhouse)?\s*([\s\S]*?)```/i);
if (fenced?.[1]) return fenced[1].trim();
+26
View File
@@ -1,4 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type {
ConnectionConfig,
DatabaseInfo,
@@ -28,6 +29,31 @@ export async function aiComplete(request: AiCompletionRequest): Promise<string>
return invoke("ai_complete", { request });
}
export interface AiStreamChunk {
session_id: string;
delta: string;
done: boolean;
}
export async function aiStream(
sessionId: string,
request: AiCompletionRequest,
onChunk: (chunk: AiStreamChunk) => void,
): Promise<void> {
const unlisten: UnlistenFn = await listen<AiStreamChunk>("ai-stream-chunk", (event) => {
if (event.payload.session_id === sessionId) {
onChunk(event.payload);
if (event.payload.done) unlisten();
}
});
try {
await invoke("ai_stream", { sessionId, request });
} catch (e) {
unlisten();
throw e;
}
}
export async function saveAiConfig(config: AiConfig): Promise<void> {
return invoke("save_ai_config", { config });
}