From 1e15249da91b923fe9ee04fc2ad9ae0b01f562ad Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 30 Apr 2026 21:46:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E6=B7=BB=E5=8A=A0=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E6=95=B0=E6=8D=AE=E5=A4=84=E7=90=86=E5=92=8C=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=20SQL=20=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/ai.rs | 84 +++++++++++++++----- src/App.vue | 3 +- src/components/editor/AiAssistant.vue | 79 ++++++++++++------ src/components/ui/scroll-area/ScrollArea.vue | 2 +- src/i18n/locales/en.ts | 1 + src/i18n/locales/zh-CN.ts | 1 + 6 files changed, 123 insertions(+), 47 deletions(-) diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 6db7cef40..89e6cd69a 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -147,6 +147,7 @@ async fn stream_claude(app: &AppHandle, client: &reqwest::Client, session_id: &s let mut stream = res.bytes_stream(); let mut buf = String::new(); + let mut finished = false; while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| e.to_string())?; buf.push_str(&String::from_utf8_lossy(&chunk)); @@ -155,27 +156,24 @@ async fn stream_claude(app: &AppHandle, client: &reqwest::Client, session_id: &s let line = buf[..pos].to_string(); buf = buf[pos + 1..].to_string(); - let line = line.trim(); - if !line.starts_with("data: ") { + let Some(data) = stream_data_payload(&line) else { continue; - } - let data = &line[6..]; + }; if data == "[DONE]" { + finished = true; break; } if let Ok(event) = serde_json::from_str::(data) { - if event["type"] == "content_block_delta" { - if let Some(text) = event["delta"]["text"].as_str() { - let _ = app.emit("ai-stream-chunk", AiStreamChunk { - session_id: session_id.to_string(), - delta: text.to_string(), - done: false, - }); - } + if let Some(text) = claude_stream_text(&event) { + emit_stream_delta(app, session_id, text); } } } + + if finished { + break; + } } let _ = app.emit("ai-stream-chunk", AiStreamChunk { @@ -224,6 +222,7 @@ async fn stream_openai(app: &AppHandle, client: &reqwest::Client, session_id: &s let mut stream = res.bytes_stream(); let mut buf = String::new(); + let mut finished = false; while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| e.to_string())?; buf.push_str(&String::from_utf8_lossy(&chunk)); @@ -232,25 +231,24 @@ async fn stream_openai(app: &AppHandle, client: &reqwest::Client, session_id: &s let line = buf[..pos].to_string(); buf = buf[pos + 1..].to_string(); - let line = line.trim(); - if !line.starts_with("data: ") { + let Some(data) = stream_data_payload(&line) else { continue; - } - let data = &line[6..]; + }; if data == "[DONE]" { + finished = true; break; } if let Ok(event) = serde_json::from_str::(data) { - if let Some(text) = event["choices"][0]["delta"]["content"].as_str() { - let _ = app.emit("ai-stream-chunk", AiStreamChunk { - session_id: session_id.to_string(), - delta: text.to_string(), - done: false, - }); + if let Some(text) = openai_stream_text(&event) { + emit_stream_delta(app, session_id, text); } } } + + if finished { + break; + } } let _ = app.emit("ai-stream-chunk", AiStreamChunk { @@ -352,3 +350,45 @@ fn extract_error(data: &serde_json::Value) -> Option { .or_else(|| data["error"].as_str()) .map(ToString::to_string) } + +fn stream_data_payload(line: &str) -> Option<&str> { + let line = line.trim(); + if line.is_empty() || line.starts_with(':') || line.starts_with("event:") || line.starts_with("id:") { + return None; + } + if let Some(data) = line.strip_prefix("data:") { + return Some(data.trim_start()); + } + if line.starts_with('{') { + return Some(line); + } + None +} + +fn claude_stream_text(event: &serde_json::Value) -> Option<&str> { + if event["type"] == "content_block_delta" { + return event["delta"]["text"].as_str(); + } + None +} + +fn openai_stream_text(event: &serde_json::Value) -> Option<&str> { + event["choices"] + .get(0) + .and_then(|choice| { + choice["delta"]["content"] + .as_str() + .or_else(|| choice["delta"]["reasoning_content"].as_str()) + .or_else(|| choice["message"]["content"].as_str()) + }) + .or_else(|| event["content"].as_str()) + .filter(|text| !text.is_empty()) +} + +fn emit_stream_delta(app: &AppHandle, session_id: &str, delta: &str) { + let _ = app.emit("ai-stream-chunk", AiStreamChunk { + session_id: session_id.to_string(), + delta: delta.to_string(), + done: false, + }); +} diff --git a/src/App.vue b/src/App.vue index bc5cf53a9..eea15211b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1052,11 +1052,12 @@ async function setupFileDrop() { -
+
diff --git a/src/components/editor/AiAssistant.vue b/src/components/editor/AiAssistant.vue index 92812a8f9..07d710a14 100644 --- a/src/components/editor/AiAssistant.vue +++ b/src/components/editor/AiAssistant.vue @@ -3,7 +3,7 @@ import { computed, nextTick, ref } from "vue"; import { useI18n } from "vue-i18n"; import { ArrowUp, Bot, Check, Copy, Database, Loader2, Replace, Server, Settings, - Trash2, X, + Play, Trash2, X, } from "lucide-vue-next"; import { Button } from "@/components/ui/button"; import { @@ -40,6 +40,7 @@ const props = defineProps<{ const emit = defineEmits<{ replaceSql: [sql: string]; + executeSql: [sql: string]; close: []; }>(); @@ -54,6 +55,11 @@ const chatTitle = computed(() => { return first ? first.content.slice(0, 30) : t("ai.newChat"); }); +const isWaitingForFirstDelta = computed(() => { + const last = messages.value[messages.value.length - 1]; + return isGenerating.value && last?.role === "assistant" && !last.content; +}); + const databaseOptions = ref([]); @@ -103,6 +109,11 @@ const providerDefaults: Record custom: { endpoint: "", model: "" }, }; +function appendAssistantDelta(assistantIdx: number, delta: string) { + messages.value[assistantIdx].content += delta; + scrollToBottom(); +} + function openSettings() { tempProvider.value = settings.aiConfig.provider; tempApiKey.value = settings.aiConfig.apiKey; @@ -129,8 +140,12 @@ function selectProvider(provider: AiProvider) { function scrollToBottom() { nextTick(() => { - const el = scrollRef.value?.$el?.querySelector("[data-radix-scroll-area-viewport]"); - if (el) el.scrollTop = el.scrollHeight; + const root = scrollRef.value?.$el as HTMLElement | undefined; + const el = root?.querySelector('[data-slot="scroll-area-viewport"]') as HTMLElement | null; + if (!el) return; + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + }); }); } @@ -163,8 +178,7 @@ async function send() { instruction: text, context, }, history, (delta) => { - messages.value[assistantIdx].content += delta; - scrollToBottom(); + appendAssistantDelta(assistantIdx, delta); }); } catch (e: any) { messages.value[assistantIdx].content = `Error: ${e.message || e}`; @@ -178,6 +192,10 @@ function applySql(code: string) { emit("replaceSql", code); } +function executeSql(code: string) { + emit("executeSql", code); +} + const copiedIndex = ref(""); async function copyCode(code: string, key: string) { @@ -209,7 +227,17 @@ function parseMessage(text: string): MessageSegment[] { lastIndex = regex.lastIndex; } if (lastIndex < text.length) { - segments.push({ type: "text", content: text.slice(lastIndex) }); + 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() }); + } + } else { + segments.push({ type: "text", content: remaining }); + } } return segments; } @@ -222,7 +250,7 @@ function formatInlineText(text: string): string {