mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-28 08:08:02 +00:00
feat: add AI streaming functionality and enhance AI assistant features
This commit is contained in:
+96
-79
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} 张表",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user