feat: enhance AI functionality with conversation management and cancellation
This commit is contained in:
parent
85af211089
commit
3f71cd6dfa
|
|
@ -1400,7 +1400,7 @@ checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
|||
|
||||
[[package]]
|
||||
name = "dbx"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ use futures::StreamExt;
|
|||
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
static AI_STREAMS: LazyLock<RwLock<HashMap<String, Arc<AtomicBool>>>> =
|
||||
LazyLock::new(|| RwLock::new(HashMap::new()));
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
|
|
@ -160,24 +167,40 @@ pub async fn ai_stream(app: AppHandle, session_id: String, request: AiCompletion
|
|||
return Err("Model is required".to_string());
|
||||
}
|
||||
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
AI_STREAMS.write().await.insert(session_id.clone(), cancelled.clone());
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match request.config.provider {
|
||||
AiProvider::Claude => stream_claude(&app, &client, &session_id, request).await,
|
||||
let result = match request.config.provider {
|
||||
AiProvider::Claude => stream_claude(&app, &client, &session_id, request, &cancelled).await,
|
||||
AiProvider::Openai | AiProvider::Custom => {
|
||||
if request.config.api_style == AiApiStyle::Responses {
|
||||
stream_responses_api(&app, &client, &session_id, request).await
|
||||
stream_responses_api(&app, &client, &session_id, request, &cancelled).await
|
||||
} else {
|
||||
stream_openai(&app, &client, &session_id, request).await
|
||||
stream_openai(&app, &client, &session_id, request, &cancelled).await
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
AI_STREAMS.write().await.remove(&session_id);
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ai_cancel_stream(session_id: String) -> Result<bool, String> {
|
||||
if let Some(flag) = AI_STREAMS.read().await.get(&session_id) {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_claude(app: &AppHandle, client: &reqwest::Client, session_id: &str, request: AiCompletionRequest) -> Result<(), String> {
|
||||
async fn stream_claude(app: &AppHandle, client: &reqwest::Client, session_id: &str, request: AiCompletionRequest, cancelled: &AtomicBool) -> Result<(), String> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
headers.insert(
|
||||
|
|
@ -213,6 +236,9 @@ async fn stream_claude(app: &AppHandle, client: &reqwest::Client, session_id: &s
|
|||
|
||||
let mut finished = false;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||
buf.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
|
|
@ -249,7 +275,7 @@ async fn stream_claude(app: &AppHandle, client: &reqwest::Client, session_id: &s
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn stream_openai(app: &AppHandle, client: &reqwest::Client, session_id: &str, request: AiCompletionRequest) -> Result<(), String> {
|
||||
async fn stream_openai(app: &AppHandle, client: &reqwest::Client, session_id: &str, request: AiCompletionRequest, cancelled: &AtomicBool) -> Result<(), String> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
headers.insert(
|
||||
|
|
@ -288,6 +314,9 @@ async fn stream_openai(app: &AppHandle, client: &reqwest::Client, session_id: &s
|
|||
|
||||
let mut finished = false;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||
buf.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
|
|
@ -536,7 +565,7 @@ async fn call_responses_api(
|
|||
.to_string())
|
||||
}
|
||||
|
||||
async fn stream_responses_api(app: &AppHandle, client: &reqwest::Client, session_id: &str, request: AiCompletionRequest) -> Result<(), String> {
|
||||
async fn stream_responses_api(app: &AppHandle, client: &reqwest::Client, session_id: &str, request: AiCompletionRequest, cancelled: &AtomicBool) -> Result<(), String> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
headers.insert(
|
||||
|
|
@ -570,6 +599,9 @@ async fn stream_responses_api(app: &AppHandle, client: &reqwest::Client, session
|
|||
|
||||
let mut finished = false;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||
buf.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
|
|
@ -609,3 +641,73 @@ async fn stream_responses_api(app: &AppHandle, client: &reqwest::Client, session
|
|||
fn responses_stream_text(event: &serde_json::Value) -> Option<&str> {
|
||||
event["delta"].as_str().filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
// --- AI Conversation Persistence ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiChatMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiConversation {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub connection_name: String,
|
||||
pub database: String,
|
||||
pub messages: Vec<AiChatMessage>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
fn conversations_file(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir.join("ai_conversations.json"))
|
||||
}
|
||||
|
||||
fn read_conversations(app: &AppHandle) -> Result<Vec<AiConversation>, String> {
|
||||
let path = conversations_file(app)?;
|
||||
if !path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let json = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn write_conversations(app: &AppHandle, conversations: &[AiConversation]) -> Result<(), String> {
|
||||
let path = conversations_file(app)?;
|
||||
let json = serde_json::to_string(conversations).map_err(|e| e.to_string())?;
|
||||
std::fs::write(path, json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
const MAX_CONVERSATIONS: usize = 50;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_ai_conversation(app: AppHandle, conversation: AiConversation) -> Result<(), String> {
|
||||
let mut conversations = read_conversations(&app)?;
|
||||
if let Some(pos) = conversations.iter().position(|c| c.id == conversation.id) {
|
||||
conversations[pos] = conversation;
|
||||
} else {
|
||||
conversations.insert(0, conversation);
|
||||
conversations.truncate(MAX_CONVERSATIONS);
|
||||
}
|
||||
write_conversations(&app, &conversations)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_ai_conversations(app: AppHandle) -> Result<Vec<AiConversation>, String> {
|
||||
read_conversations(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_ai_conversation(app: AppHandle, id: String) -> Result<(), String> {
|
||||
let conversations: Vec<AiConversation> = read_conversations(&app)?
|
||||
.into_iter()
|
||||
.filter(|c| c.id != id)
|
||||
.collect();
|
||||
write_conversations(&app, &conversations)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,9 +38,13 @@ pub fn run() {
|
|||
.invoke_handler(tauri::generate_handler![
|
||||
commands::ai::ai_complete,
|
||||
commands::ai::ai_stream,
|
||||
commands::ai::ai_cancel_stream,
|
||||
commands::ai::ai_test_connection,
|
||||
commands::ai::save_ai_config,
|
||||
commands::ai::load_ai_config,
|
||||
commands::ai::save_ai_conversation,
|
||||
commands::ai::load_ai_conversations,
|
||||
commands::ai::delete_ai_conversation,
|
||||
commands::connection::test_connection,
|
||||
commands::connection::connect_db,
|
||||
commands::connection::disconnect_db,
|
||||
|
|
|
|||
20
src/App.vue
20
src/App.vue
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, type Ref } from "vue";
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, type Ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { DatabaseZap, FilePlus2, Play, Loader2, Square, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Bot, Pin, AlignLeft, CloudDownload, ArrowLeftRight } from "lucide-vue-next";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
|
|
@ -62,6 +62,7 @@ const showConnectionDialog = ref(false);
|
|||
const showHistory = ref(false);
|
||||
const showAiPanel = ref(localStorage.getItem("dbx-ai-panel-open") !== "false");
|
||||
const aiPanelWidth = ref(Number(localStorage.getItem("dbx-ai-panel-width")) || 360);
|
||||
const aiAssistantRef = ref<InstanceType<typeof AiAssistant> | null>(null);
|
||||
const sidebarWidth = ref(Number(localStorage.getItem("dbx-sidebar-width")) || 260);
|
||||
const historyWidth = ref(Number(localStorage.getItem("dbx-history-width")) || 288);
|
||||
|
||||
|
|
@ -70,6 +71,16 @@ function toggleAiPanel() {
|
|||
localStorage.setItem("dbx-ai-panel-open", String(showAiPanel.value));
|
||||
}
|
||||
|
||||
function fixWithAi(errorMessage: string) {
|
||||
if (!showAiPanel.value) {
|
||||
showAiPanel.value = true;
|
||||
localStorage.setItem("dbx-ai-panel-open", "true");
|
||||
}
|
||||
nextTick(() => {
|
||||
aiAssistantRef.value?.triggerAction("fix", errorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
function startPanelResize(widthRef: Ref<number>, storageKey: string, direction: 'left' | 'right') {
|
||||
return (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -954,6 +965,12 @@ async function setupFileDrop() {
|
|||
<Pane :size="60" :min-size="20">
|
||||
<div class="h-full flex flex-col">
|
||||
<DataGrid v-if="activeTab.result" :key="activeTab.id" class="flex-1 min-h-0" :result="activeTab.result" :sql="activeTab.lastExecutedSql || activeTab.sql" :loading="activeTab.isExecuting" />
|
||||
<div v-if="activeTab.result?.columns.includes('Error')" class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/5">
|
||||
<Bot class="h-3.5 w-3.5 text-destructive" />
|
||||
<button class="text-xs text-destructive hover:underline" @click="fixWithAi(String(activeTab.result?.rows?.[0]?.[0] ?? ''))">
|
||||
{{ t('ai.fixWithAi') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="activeTab.isExecuting" class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
|
||||
<div class="flex items-center">
|
||||
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
||||
|
|
@ -1124,6 +1141,7 @@ async function setupFileDrop() {
|
|||
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startAiPanelResize" />
|
||||
<div class="h-full min-h-0 overflow-hidden">
|
||||
<AiAssistant
|
||||
ref="aiAssistantRef"
|
||||
:tab="activeTab"
|
||||
:connection="activeConnection"
|
||||
@replace-sql="replaceActiveSql"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { computed, nextTick, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
ArrowUp, Bot, Check, Copy, Database, Loader2, Replace, Server, Settings,
|
||||
Play, Trash2, X,
|
||||
ArrowUp, ArrowRightLeft, Bot, Check, Copy, Database, HelpCircle, History,
|
||||
Loader2, MessageSquarePlus, Replace, Server, Settings, Play, Square, Trash2,
|
||||
Wand2, Wrench, X, Zap, TestTube,
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -12,14 +13,20 @@ import {
|
|||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useSettingsStore, type AiProvider, type AiApiStyle } from "@/stores/settingsStore";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { buildAiContext, runAiStream } from "@/lib/ai";
|
||||
import { listDatabases, redisListDatabases, mongoListDatabases, aiTestConnection } from "@/lib/tauri";
|
||||
import { buildAiContext, runAiStream, type AiAction } from "@/lib/ai";
|
||||
import {
|
||||
listDatabases, redisListDatabases, mongoListDatabases, aiTestConnection, aiCancelStream,
|
||||
saveAiConversation, loadAiConversations, deleteAiConversation, type AiConversation,
|
||||
} from "@/lib/tauri";
|
||||
import type { AiMessage } from "@/lib/tauri";
|
||||
import type { ConnectionConfig, QueryTab } from "@/types/database";
|
||||
|
||||
|
|
@ -49,6 +56,31 @@ const messages = ref<ChatMessage[]>([]);
|
|||
const isGenerating = ref(false);
|
||||
const showSettings = ref(false);
|
||||
const scrollRef = ref<InstanceType<typeof ScrollArea> | null>(null);
|
||||
const activeAction = ref<AiAction>("generate");
|
||||
const currentSessionId = ref("");
|
||||
const conversationId = ref("");
|
||||
const conversations = ref<AiConversation[]>([]);
|
||||
const showConversationList = ref(false);
|
||||
|
||||
const actionButtons: { action: AiAction; icon: any; key: string }[] = [
|
||||
{ action: "generate", icon: Wand2, key: "ai.actions.generate" },
|
||||
{ action: "explain", icon: HelpCircle, key: "ai.actions.explain" },
|
||||
{ action: "optimize", icon: Zap, key: "ai.actions.optimize" },
|
||||
{ action: "fix", icon: Wrench, key: "ai.actions.fix" },
|
||||
{ action: "convert", icon: ArrowRightLeft, key: "ai.actions.convert" },
|
||||
{ action: "sampleData", icon: TestTube, key: "ai.actions.sampleData" },
|
||||
];
|
||||
|
||||
function selectAction(action: AiAction) {
|
||||
activeAction.value = action;
|
||||
if (action === "fix" && props.tab?.result) {
|
||||
const cols = props.tab.result.columns;
|
||||
if (cols.includes("Error")) {
|
||||
const errVal = props.tab.result.rows[0]?.[0];
|
||||
if (errVal != null) prompt.value = String(errVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chatTitle = computed(() => {
|
||||
const first = messages.value.find((m) => m.role === "user");
|
||||
|
|
@ -60,6 +92,8 @@ const isWaitingForFirstDelta = computed(() => {
|
|||
return isGenerating.value && last?.role === "assistant" && !last.content;
|
||||
});
|
||||
|
||||
const activePlaceholder = computed(() => t(`ai.placeholders.${activeAction.value}`));
|
||||
|
||||
|
||||
const databaseOptions = ref<string[]>([]);
|
||||
|
||||
|
|
@ -194,6 +228,8 @@ async function send() {
|
|||
isGenerating.value = true;
|
||||
messages.value.push({ role: "assistant", content: "" });
|
||||
const assistantIdx = messages.value.length - 1;
|
||||
const sessionId = crypto.randomUUID();
|
||||
currentSessionId.value = sessionId;
|
||||
try {
|
||||
const context = await buildAiContext(props.tab, props.connection);
|
||||
const history: AiMessage[] = messages.value.slice(0, -2).map((m) => ({
|
||||
|
|
@ -202,20 +238,29 @@ async function send() {
|
|||
}));
|
||||
await runAiStream({
|
||||
config: settings.aiConfig,
|
||||
action: "generate",
|
||||
action: activeAction.value,
|
||||
instruction: text,
|
||||
context,
|
||||
}, history, (delta) => {
|
||||
appendAssistantDelta(assistantIdx, delta);
|
||||
});
|
||||
}, sessionId);
|
||||
} catch (e: any) {
|
||||
messages.value[assistantIdx].content = `Error: ${e.message || e}`;
|
||||
} finally {
|
||||
isGenerating.value = false;
|
||||
activeAction.value = "generate";
|
||||
currentSessionId.value = "";
|
||||
persistConversation();
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelStream() {
|
||||
if (currentSessionId.value) {
|
||||
await aiCancelStream(currentSessionId.value).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function applySql(code: string) {
|
||||
emit("replaceSql", code);
|
||||
}
|
||||
|
|
@ -234,8 +279,62 @@ async function copyCode(code: string, key: string) {
|
|||
|
||||
function clearMessages() {
|
||||
messages.value = [];
|
||||
conversationId.value = "";
|
||||
}
|
||||
|
||||
async function persistConversation() {
|
||||
if (!messages.value.length || !props.connection) return;
|
||||
if (!conversationId.value) conversationId.value = crypto.randomUUID();
|
||||
const first = messages.value.find((m) => m.role === "user");
|
||||
await saveAiConversation({
|
||||
id: conversationId.value,
|
||||
title: first ? first.content.slice(0, 50) : "Untitled",
|
||||
connectionName: props.connection.name,
|
||||
database: props.tab?.database || "",
|
||||
messages: messages.value.map((m) => ({ role: m.role, content: m.content })),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function loadConversationList() {
|
||||
conversations.value = await loadAiConversations().catch(() => []);
|
||||
showConversationList.value = true;
|
||||
}
|
||||
|
||||
function selectConversation(conv: AiConversation) {
|
||||
conversationId.value = conv.id;
|
||||
messages.value = conv.messages.map((m) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
}));
|
||||
showConversationList.value = false;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
async function deleteConversation(id: string) {
|
||||
await deleteAiConversation(id).catch(() => {});
|
||||
conversations.value = conversations.value.filter((c) => c.id !== id);
|
||||
if (conversationId.value === id) clearMessages();
|
||||
}
|
||||
|
||||
function startNewChat() {
|
||||
clearMessages();
|
||||
showConversationList.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
conversations.value = await loadAiConversations().catch(() => []);
|
||||
});
|
||||
|
||||
function triggerAction(action: AiAction, instruction?: string) {
|
||||
activeAction.value = action;
|
||||
if (instruction) prompt.value = instruction;
|
||||
send();
|
||||
}
|
||||
|
||||
defineExpose({ triggerAction });
|
||||
|
||||
interface MessageSegment {
|
||||
type: "text" | "code";
|
||||
content: string;
|
||||
|
|
@ -244,29 +343,33 @@ interface MessageSegment {
|
|||
|
||||
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) {
|
||||
const remaining = text.slice(lastIndex);
|
||||
const unclosed = remaining.match(/```(sql|mysql|postgresql|sqlite|tsql|clickhouse)?\s*([\s\S]*)/i);
|
||||
if (unclosed) {
|
||||
const before = remaining.slice(0, unclosed.index);
|
||||
if (before.trim()) segments.push({ type: "text", content: before });
|
||||
if (unclosed[2].trim()) {
|
||||
segments.push({ type: "code", lang: (unclosed[1] || "sql").toUpperCase(), content: unclosed[2].trim() });
|
||||
const lines = text.split("\n");
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const fenceMatch = lines[i].match(/^```(sql|mysql|postgresql|sqlite|tsql|clickhouse)?\s*$/i);
|
||||
if (fenceMatch) {
|
||||
const lang = (fenceMatch[1] || "sql").toUpperCase();
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length) i++;
|
||||
const content = codeLines.join("\n").trim();
|
||||
if (content) segments.push({ type: "code", lang, content });
|
||||
} else {
|
||||
segments.push({ type: "text", content: remaining });
|
||||
const textLines: string[] = [];
|
||||
while (i < lines.length && !/^```(sql|mysql|postgresql|sqlite|tsql|clickhouse)?\s*$/i.test(lines[i])) {
|
||||
textLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
const content = textLines.join("\n");
|
||||
if (content.trim()) segments.push({ type: "text", content });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
|
|
@ -280,8 +383,13 @@ function formatInlineText(text: string): string {
|
|||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<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="startNewChat" :title="t('ai.newChat')">
|
||||
<MessageSquarePlus class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :class="{ 'bg-accent': showConversationList }" @click="loadConversationList" :title="t('history.title')">
|
||||
<History class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<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>
|
||||
|
|
@ -293,6 +401,24 @@ function formatInlineText(text: string): string {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="showConversationList" class="border-b max-h-48 overflow-auto">
|
||||
<div v-if="!conversations.length" class="p-3 text-xs text-muted-foreground text-center">
|
||||
{{ t('history.empty') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="flex items-center gap-2 px-3 py-1.5 hover:bg-muted cursor-pointer text-xs"
|
||||
:class="{ 'bg-muted': conv.id === conversationId }"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<span class="flex-1 truncate">{{ conv.title }}</span>
|
||||
<button class="shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive" @click.stop="deleteConversation(conv.id)">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="messages.length === 0" class="flex-1 min-h-0 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>
|
||||
|
|
@ -372,18 +498,43 @@ function formatInlineText(text: string): string {
|
|||
</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 || !props.tab?.database"
|
||||
@keydown.enter.exact="send"
|
||||
/>
|
||||
<textarea
|
||||
v-model="prompt"
|
||||
rows="3"
|
||||
class="w-full resize-none bg-transparent text-xs outline-none placeholder:text-muted-foreground mb-1"
|
||||
:placeholder="activePlaceholder"
|
||||
:disabled="isGenerating"
|
||||
@keydown.enter.exact="send"
|
||||
/>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<component :is="actionButtons.find(b => b.action === activeAction)?.icon" class="h-3 w-3" />
|
||||
<span>{{ t(`ai.actions.${activeAction}`) }}</span>
|
||||
<svg class="h-3 w-3 opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" class="w-max min-w-0">
|
||||
<DropdownMenuItem v-for="btn in actionButtons" :key="btn.action" class="text-xs gap-1.5" @click="selectAction(btn.action)">
|
||||
<component :is="btn.icon" class="h-3 w-3" />
|
||||
<span>{{ t(btn.key) }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span class="flex-1" />
|
||||
<button
|
||||
v-if="isGenerating"
|
||||
class="h-7 w-7 shrink-0 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center"
|
||||
:title="t('ai.stopGenerating')"
|
||||
@click="cancelStream"
|
||||
>
|
||||
<Square class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
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() || !props.tab?.database"
|
||||
:disabled="!prompt.trim() || !props.tab?.database"
|
||||
@click="send"
|
||||
>
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -164,6 +164,13 @@ export default {
|
|||
copied: "Copied",
|
||||
replace: "Replace Editor",
|
||||
append: "Append to Editor",
|
||||
apply: "Apply to Editor",
|
||||
clear: "Clear Chat",
|
||||
welcome: "Tell me what you'd like to query, and I'll write the SQL",
|
||||
newChat: "New Chat",
|
||||
thinking: "Thinking...",
|
||||
stopGenerating: "Stop generating",
|
||||
fixWithAi: "Fix with AI",
|
||||
truncated: "Context truncated",
|
||||
contextSummary: "{database} · {tables} tables",
|
||||
settingsHint: "The config is stored in the local app data directory. Requests are sent by the Tauri backend instead of directly from the frontend.",
|
||||
|
|
|
|||
|
|
@ -169,6 +169,8 @@ export default {
|
|||
welcome: "聊聊你想查什么,我来写 SQL",
|
||||
newChat: "新对话",
|
||||
thinking: "思考中...",
|
||||
stopGenerating: "停止生成",
|
||||
fixWithAi: "用 AI 修复",
|
||||
truncated: "上下文已截断",
|
||||
contextSummary: "{database} · {tables} 张表",
|
||||
settingsHint: "配置会保存在本机应用数据目录中。请求由 Tauri 后端发出,避免在前端直接暴露给模型服务。",
|
||||
|
|
|
|||
158
src/lib/ai.ts
158
src/lib/ai.ts
|
|
@ -1,5 +1,5 @@
|
|||
import type { AiConfig } from "@/stores/settingsStore";
|
||||
import type { ColumnInfo, ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
|
||||
import type { ColumnInfo, ConnectionConfig, DatabaseType, ForeignKeyInfo, IndexInfo, QueryResult, QueryTab } from "@/types/database";
|
||||
import * as api from "@/lib/tauri";
|
||||
import { currentLocale } from "@/i18n";
|
||||
|
||||
|
|
@ -10,6 +10,8 @@ export interface AiSchemaTable {
|
|||
name: string;
|
||||
tableType: string;
|
||||
columns: ColumnInfo[];
|
||||
indexes?: IndexInfo[];
|
||||
foreignKeys?: ForeignKeyInfo[];
|
||||
}
|
||||
|
||||
export interface AiContext {
|
||||
|
|
@ -30,20 +32,40 @@ export interface AiRequestInput {
|
|||
context: AiContext;
|
||||
}
|
||||
|
||||
const ACTION_INSTRUCTIONS: Record<AiAction, string> = {
|
||||
generate: "Generate a SQL query that satisfies the user's request.",
|
||||
explain: "Explain the current SQL clearly and point out risky operations or assumptions.",
|
||||
optimize: "Rewrite or suggest improvements for the current SQL. Prefer a complete improved SQL query first, followed by short notes.",
|
||||
fix: "Fix the current SQL using the provided error/result context. Return the corrected SQL first, followed by short notes if needed.",
|
||||
convert: "Convert the current SQL to the target dialect requested by the user. Return the converted SQL first.",
|
||||
sampleData: "Generate safe sample SQL statements or mock data for the current schema. Do not use real production data.",
|
||||
const ACTION_INSTRUCTIONS: Record<AiAction, { en: string; zh: string }> = {
|
||||
generate: {
|
||||
en: "Generate a SQL query that satisfies the user's request. Return the SQL in a ```sql code block first, followed by a brief note if needed. Use foreign key relationships from the schema to infer correct JOIN conditions.",
|
||||
zh: "根据用户请求生成 SQL。先在 ```sql 代码块中返回 SQL,必要时附简短说明。利用 Schema 中的外键关系推断正确的 JOIN 条件。",
|
||||
},
|
||||
explain: {
|
||||
en: "Explain the current SQL step by step. Point out risky operations, implicit assumptions, and potential performance issues. Reference index and foreign key info from the schema when relevant.",
|
||||
zh: "逐步解释当前 SQL。指出危险操作、隐含假设和潜在性能问题。结合 Schema 中的索引和外键信息分析。",
|
||||
},
|
||||
optimize: {
|
||||
en: "Rewrite or suggest improvements for the current SQL. Return the improved SQL in a ```sql code block first, followed by short notes explaining the changes. Use the index information in the schema to suggest index-aware optimizations (e.g., avoid full table scans, leverage existing indexes).",
|
||||
zh: "重写或优化当前 SQL。先在 ```sql 代码块中返回优化后的 SQL,然后简要说明改动。利用 Schema 中的索引信息建议索引友好的优化(如避免全表扫描、利用现有索引)。",
|
||||
},
|
||||
fix: {
|
||||
en: "Fix the current SQL using the provided error message and result context. Return the corrected SQL in a ```sql code block first, followed by a brief explanation of the root cause.",
|
||||
zh: "根据报错信息和结果上下文修复当前 SQL。先在 ```sql 代码块中返回修正后的 SQL,再简要说明根因。",
|
||||
},
|
||||
convert: {
|
||||
en: "Convert the current SQL to the target dialect requested by the user. Return the converted SQL in a ```sql code block first. Note any syntax differences or incompatibilities.",
|
||||
zh: "将当前 SQL 转换为用户指定的目标方言。先在 ```sql 代码块中返回转换后的 SQL,再说明语法差异。",
|
||||
},
|
||||
sampleData: {
|
||||
en: "Generate safe sample INSERT statements or mock data for the current schema. Do not use real production data. Return SQL in a ```sql code block.",
|
||||
zh: "为当前 Schema 生成安全的示例 INSERT 语句或模拟数据。不使用真实生产数据。在 ```sql 代码块中返回 SQL。",
|
||||
},
|
||||
};
|
||||
|
||||
export async function runAiAction(input: AiRequestInput, history?: api.AiMessage[]): Promise<string> {
|
||||
const isZh = currentLocale() === "zh-CN";
|
||||
const systemPrompt = buildSystemPrompt(input.action, input.context);
|
||||
const instruction = isZh ? ACTION_INSTRUCTIONS[input.action].zh : ACTION_INSTRUCTIONS[input.action].en;
|
||||
const userPrompt = [
|
||||
`Action: ${input.action}`,
|
||||
ACTION_INSTRUCTIONS[input.action],
|
||||
instruction,
|
||||
"",
|
||||
"User request:",
|
||||
input.instruction.trim() || "(No extra instruction provided.)",
|
||||
|
|
@ -54,12 +76,13 @@ export async function runAiAction(input: AiRequestInput, history?: api.AiMessage
|
|||
{ role: "user", content: userPrompt },
|
||||
];
|
||||
|
||||
const params = actionParams(input.action);
|
||||
return api.aiComplete({
|
||||
config: input.config,
|
||||
systemPrompt,
|
||||
messages,
|
||||
maxTokens: 2400,
|
||||
temperature: 0.15,
|
||||
maxTokens: params.maxTokens,
|
||||
temperature: params.temperature,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -67,11 +90,14 @@ export async function runAiStream(
|
|||
input: AiRequestInput,
|
||||
history: api.AiMessage[] | undefined,
|
||||
onDelta: (delta: string) => void,
|
||||
sessionId?: string,
|
||||
): Promise<void> {
|
||||
const isZh = currentLocale() === "zh-CN";
|
||||
const systemPrompt = buildSystemPrompt(input.action, input.context);
|
||||
const instruction = isZh ? ACTION_INSTRUCTIONS[input.action].zh : ACTION_INSTRUCTIONS[input.action].en;
|
||||
const userPrompt = [
|
||||
`Action: ${input.action}`,
|
||||
ACTION_INSTRUCTIONS[input.action],
|
||||
instruction,
|
||||
"",
|
||||
"User request:",
|
||||
input.instruction.trim() || "(No extra instruction provided.)",
|
||||
|
|
@ -82,19 +108,28 @@ export async function runAiStream(
|
|||
{ role: "user", content: userPrompt },
|
||||
];
|
||||
|
||||
const sessionId = crypto.randomUUID();
|
||||
const sid = sessionId || crypto.randomUUID();
|
||||
const params = actionParams(input.action);
|
||||
|
||||
await api.aiStream(sessionId, {
|
||||
await api.aiStream(sid, {
|
||||
config: input.config,
|
||||
systemPrompt,
|
||||
messages,
|
||||
maxTokens: 2400,
|
||||
temperature: 0.15,
|
||||
maxTokens: params.maxTokens,
|
||||
temperature: params.temperature,
|
||||
}, (chunk) => {
|
||||
if (!chunk.done && chunk.delta) onDelta(chunk.delta);
|
||||
});
|
||||
}
|
||||
|
||||
function actionParams(action: AiAction): { maxTokens: number; temperature: number } {
|
||||
switch (action) {
|
||||
case "explain": return { maxTokens: 3200, temperature: 0.2 };
|
||||
case "sampleData": return { maxTokens: 2400, temperature: 0.1 };
|
||||
default: return { maxTokens: 2400, temperature: 0.15 };
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
@ -110,7 +145,7 @@ export function buildSystemPrompt(action: AiAction, context: AiContext): string
|
|||
|
||||
const isZh = currentLocale() === "zh-CN";
|
||||
|
||||
return [
|
||||
const lines: string[] = [
|
||||
isZh
|
||||
? "你是 DBX 内置的数据库助手。用中文回复。"
|
||||
: "You are DBX's built-in database assistant. Reply in English.",
|
||||
|
|
@ -118,34 +153,50 @@ export function buildSystemPrompt(action: AiAction, context: AiContext): string
|
|||
? "精确、保守,根据当前数据库方言生成 SQL。"
|
||||
: "Be precise, conservative, and adapt SQL to the active database dialect.",
|
||||
isZh
|
||||
? "下面的 Schema 上下文已包含表和列信息,直接使用即可。不要查询 information_schema 或系统表来获取结构信息,直接针对用户的实际表编写查询。"
|
||||
: "The schema context below already contains table and column information — use it directly. Do NOT query information_schema or system tables to discover schema; write queries against the user's actual tables.",
|
||||
? "下面的 Schema 上下文已包含表、列、索引和外键信息,直接使用即可。不要查询 information_schema 或系统表来获取结构信息。"
|
||||
: "The schema context below already contains tables, columns, indexes, and foreign keys — use it directly. Do NOT query information_schema or system tables.",
|
||||
isZh
|
||||
? "当用户要求分析或查看某个表时,生成 SELECT 查询获取数据,而不是查询元数据。"
|
||||
: "When the user asks to 'analyze' or 'look at' a table, generate a SELECT query to retrieve data, not a metadata query.",
|
||||
isZh
|
||||
? "不要编造 Schema 中不存在的表或列,除非用户明确要求假设示例。"
|
||||
: "Never invent tables or columns that are not present in the schema context unless the user explicitly asks for hypothetical examples.",
|
||||
? "不要编造 Schema 中不存在的表或列。"
|
||||
: "Never invent tables or columns that are not in the schema context.",
|
||||
isZh
|
||||
? "对于 DROP、DELETE、TRUNCATE、ALTER 或没有 WHERE 子句的 UPDATE 等危险语句,简要警告并优先提供安全的 SELECT 预览。"
|
||||
: "For destructive statements such as DROP, DELETE, TRUNCATE, ALTER, or UPDATE without a clear WHERE clause, warn briefly and prefer a safer SELECT preview when appropriate.",
|
||||
? "对于 DROP、DELETE、TRUNCATE、ALTER 或没有 WHERE 的 UPDATE,简要警告并优先提供安全的 SELECT 预览。"
|
||||
: "For destructive statements (DROP, DELETE, TRUNCATE, ALTER, UPDATE without WHERE), warn briefly and prefer a safer SELECT preview.",
|
||||
];
|
||||
|
||||
if (action === "optimize") {
|
||||
lines.push(isZh
|
||||
? "利用 Schema 中的索引信息建议优化。指出哪些查询条件可以命中索引、哪些会导致全表扫描。"
|
||||
: "Use the index information in the schema to suggest optimizations. Point out which conditions hit indexes and which cause full table scans.");
|
||||
} else if (action === "generate") {
|
||||
lines.push(isZh
|
||||
? "利用外键关系推断 JOIN 条件。生成操作优先返回 SQL,避免长篇解释。"
|
||||
: "Use foreign key relationships to infer JOIN conditions. Return the SQL first and avoid long explanations.");
|
||||
} else if (action === "fix") {
|
||||
lines.push(isZh
|
||||
? "仔细分析错误信息,定位根因。先返回修正后的 SQL,再简要解释。"
|
||||
: "Carefully analyze the error message to identify the root cause. Return the corrected SQL first, then briefly explain.");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
isZh
|
||||
? "返回 SQL 时放在 ```sql 代码块中。额外说明简短实用即可。"
|
||||
: "When returning SQL, put the SQL in a fenced ```sql code block. Keep extra explanation short and practical.",
|
||||
action === "generate"
|
||||
? (isZh ? "生成操作优先返回 SQL,避免长篇解释。" : "For generate actions, return the SQL first and avoid long explanations.")
|
||||
: "",
|
||||
? "返回 SQL 时放在 ```sql 代码块中。额外说明简短实用。"
|
||||
: "Put SQL in a fenced ```sql code block. Keep extra explanation short and practical.",
|
||||
"",
|
||||
`Database type: ${context.databaseType}`,
|
||||
`Connection: ${context.connectionName}`,
|
||||
`Database: ${context.database}`,
|
||||
context.truncated ? "Schema context is truncated." : "Schema context is complete within the current budget.",
|
||||
context.truncated ? "Schema context is truncated." : "Schema context is complete.",
|
||||
"",
|
||||
`Current SQL:\n${context.currentSql.trim() || "(empty)"}`,
|
||||
lastError,
|
||||
resultPreview,
|
||||
`Schema:\n${schema}`,
|
||||
].filter(Boolean).join("\n");
|
||||
);
|
||||
|
||||
return lines.filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function formatSchema(context: AiContext): string {
|
||||
|
|
@ -153,16 +204,33 @@ function formatSchema(context: AiContext): string {
|
|||
|
||||
return context.tables.map((table) => {
|
||||
const name = table.schema ? `${table.schema}.${table.name}` : table.name;
|
||||
const columns = table.columns.map((column) => {
|
||||
const lines: string[] = [`${name} (${table.tableType})`];
|
||||
|
||||
for (const column of table.columns) {
|
||||
const flags = [
|
||||
column.is_primary_key ? "primary key" : "",
|
||||
column.is_nullable ? "nullable" : "not null",
|
||||
column.is_primary_key ? "PK" : "",
|
||||
column.is_nullable ? "nullable" : "NOT NULL",
|
||||
column.column_default ? `default ${column.column_default}` : "",
|
||||
column.extra || "",
|
||||
].filter(Boolean).join(", ");
|
||||
return ` - ${column.name}: ${column.data_type}${flags ? ` (${flags})` : ""}`;
|
||||
});
|
||||
return [`${name} (${table.tableType})`, ...columns].join("\n");
|
||||
lines.push(` - ${column.name}: ${column.data_type}${flags ? ` (${flags})` : ""}`);
|
||||
}
|
||||
|
||||
if (table.indexes?.length) {
|
||||
for (const idx of table.indexes) {
|
||||
if (idx.is_primary) continue;
|
||||
const unique = idx.is_unique ? "UNIQUE " : "";
|
||||
lines.push(` Index: ${unique}${idx.name}(${idx.columns.join(", ")})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (table.foreignKeys?.length) {
|
||||
for (const fk of table.foreignKeys) {
|
||||
lines.push(` FK: ${fk.column} → ${fk.ref_table}.${fk.ref_column}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
|
|
@ -177,11 +245,19 @@ export async function buildAiContext(
|
|||
let truncated = false;
|
||||
|
||||
if (tab.tableMeta) {
|
||||
const s = tab.tableMeta.schema ?? "";
|
||||
const tName = tab.tableMeta.tableName;
|
||||
const [indexes, foreignKeys] = await Promise.all([
|
||||
api.listIndexes(tab.connectionId, tab.database, s, tName).catch(() => [] as IndexInfo[]),
|
||||
api.listForeignKeys(tab.connectionId, tab.database, s, tName).catch(() => [] as ForeignKeyInfo[]),
|
||||
]);
|
||||
tables.push({
|
||||
schema: tab.tableMeta.schema,
|
||||
name: tab.tableMeta.tableName,
|
||||
name: tName,
|
||||
tableType: "TABLE",
|
||||
columns: tab.tableMeta.columns.slice(0, maxColumnsPerTable),
|
||||
indexes,
|
||||
foreignKeys,
|
||||
});
|
||||
truncated = tab.tableMeta.columns.length > maxColumnsPerTable;
|
||||
} else if (!["redis", "mongodb"].includes(connection.db_type)) {
|
||||
|
|
@ -194,12 +270,18 @@ export async function buildAiContext(
|
|||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const columns = await api.getColumns(tab.connectionId, tab.database, schema, table.name);
|
||||
const [columns, indexes, foreignKeys] = await Promise.all([
|
||||
api.getColumns(tab.connectionId, tab.database, schema, table.name),
|
||||
api.listIndexes(tab.connectionId, tab.database, schema, table.name).catch(() => [] as IndexInfo[]),
|
||||
api.listForeignKeys(tab.connectionId, tab.database, schema, table.name).catch(() => [] as ForeignKeyInfo[]),
|
||||
]);
|
||||
tables.push({
|
||||
schema: schema === tab.database && connection.db_type !== "postgres" ? undefined : schema,
|
||||
name: table.name,
|
||||
tableType: table.table_type,
|
||||
columns: columns.slice(0, maxColumnsPerTable),
|
||||
indexes,
|
||||
foreignKeys,
|
||||
});
|
||||
if (columns.length > maxColumnsPerTable) truncated = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,10 +62,43 @@ export async function aiTestConnection(config: AiConfig): Promise<string> {
|
|||
return invoke("ai_test_connection", { config });
|
||||
}
|
||||
|
||||
export async function aiCancelStream(sessionId: string): Promise<boolean> {
|
||||
return invoke("ai_cancel_stream", { sessionId });
|
||||
}
|
||||
|
||||
export async function loadAiConfig(): Promise<AiConfig | null> {
|
||||
return invoke("load_ai_config");
|
||||
}
|
||||
|
||||
// --- AI Conversations ---
|
||||
|
||||
export interface AiChatMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AiConversation {
|
||||
id: string;
|
||||
title: string;
|
||||
connectionName: string;
|
||||
database: string;
|
||||
messages: AiChatMessage[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export async function saveAiConversation(conversation: AiConversation): Promise<void> {
|
||||
return invoke("save_ai_conversation", { conversation });
|
||||
}
|
||||
|
||||
export async function loadAiConversations(): Promise<AiConversation[]> {
|
||||
return invoke("load_ai_conversations");
|
||||
}
|
||||
|
||||
export async function deleteAiConversation(id: string): Promise<void> {
|
||||
return invoke("delete_ai_conversation", { id });
|
||||
}
|
||||
|
||||
export async function testConnection(config: ConnectionConfig): Promise<string> {
|
||||
return invoke("test_connection", { config });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue