From eab5556b6f0cfb65c358c06b97c7b9e99395e830 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 2 May 2026 20:43:46 +0800 Subject: [PATCH] feat: enhance AI streaming with reasoning support and UI updates --- src-tauri/src/commands/ai.rs | 28 +++++++++++- src/components/editor/AiAssistant.vue | 63 ++++++++++++++++++++++++--- src/i18n/locales/en.ts | 1 + src/i18n/locales/zh-CN.ts | 1 + src/lib/ai.ts | 6 ++- src/lib/tauri.ts | 2 + 6 files changed, 93 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index f98c2d0b0e..d7283edb00 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -152,6 +152,8 @@ pub async fn ai_complete(request: AiCompletionRequest) -> Result struct AiStreamChunk { session_id: String, delta: String, + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_delta: Option, done: bool, } @@ -269,6 +271,7 @@ async fn stream_claude(app: &AppHandle, client: &reqwest::Client, session_id: &s let _ = app.emit("ai-stream-chunk", AiStreamChunk { session_id: session_id.to_string(), delta: String::new(), + reasoning_delta: None, done: true, }); @@ -333,6 +336,9 @@ async fn stream_openai(app: &AppHandle, client: &reqwest::Client, session_id: &s } if let Ok(event) = serde_json::from_str::(data) { + if let Some(reasoning) = openai_stream_reasoning(&event) { + emit_stream_reasoning(app, session_id, reasoning); + } if let Some(text) = openai_stream_text(&event) { emit_stream_delta(app, session_id, text); } @@ -347,6 +353,7 @@ async fn stream_openai(app: &AppHandle, client: &reqwest::Client, session_id: &s let _ = app.emit("ai-stream-chunk", AiStreamChunk { session_id: session_id.to_string(), delta: String::new(), + reasoning_delta: None, done: true, }); @@ -488,17 +495,33 @@ fn openai_stream_text(event: &serde_json::Value) -> Option<&str> { .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 openai_stream_reasoning(event: &serde_json::Value) -> Option<&str> { + event["choices"] + .get(0) + .and_then(|choice| choice["delta"]["reasoning_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(), + reasoning_delta: None, + done: false, + }); +} + +fn emit_stream_reasoning(app: &AppHandle, session_id: &str, reasoning: &str) { + let _ = app.emit("ai-stream-chunk", AiStreamChunk { + session_id: session_id.to_string(), + delta: String::new(), + reasoning_delta: Some(reasoning.to_string()), done: false, }); } @@ -632,6 +655,7 @@ async fn stream_responses_api(app: &AppHandle, client: &reqwest::Client, session let _ = app.emit("ai-stream-chunk", AiStreamChunk { session_id: session_id.to_string(), delta: String::new(), + reasoning_delta: None, done: true, }); @@ -649,6 +673,8 @@ fn responses_stream_text(event: &serde_json::Value) -> Option<&str> { pub struct AiChatMessage { pub role: String, pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/components/editor/AiAssistant.vue b/src/components/editor/AiAssistant.vue index a844fa73eb..3550e91e67 100644 --- a/src/components/editor/AiAssistant.vue +++ b/src/components/editor/AiAssistant.vue @@ -2,7 +2,7 @@ import { computed, nextTick, onMounted, ref } from "vue"; import { useI18n } from "vue-i18n"; import { - ArrowUp, ArrowRightLeft, Bot, Check, Copy, Database, HelpCircle, History, + ArrowUp, ArrowRightLeft, Bot, Check, ChevronRight, Copy, Database, HelpCircle, History, Loader2, MessageSquarePlus, Replace, Server, Settings, Play, Square, Trash2, Wand2, Wrench, X, Zap, TestTube, } from "lucide-vue-next"; @@ -38,6 +38,8 @@ const queryStore = useQueryStore(); interface ChatMessage { role: "user" | "assistant"; content: string; + reasoning?: string; + isThinking?: boolean; } const props = defineProps<{ @@ -89,7 +91,7 @@ const chatTitle = computed(() => { const isWaitingForFirstDelta = computed(() => { const last = messages.value[messages.value.length - 1]; - return isGenerating.value && last?.role === "assistant" && !last.content; + return isGenerating.value && last?.role === "assistant" && !last.content && !last.reasoning; }); const activePlaceholder = computed(() => t(`ai.placeholders.${activeAction.value}`)); @@ -145,10 +147,32 @@ const providerDefaults: Record }; function appendAssistantDelta(assistantIdx: number, delta: string) { - messages.value[assistantIdx].content += delta; + const msg = messages.value[assistantIdx]; + if (msg.isThinking) msg.isThinking = false; + msg.content += delta; scrollToBottom(); } +function appendAssistantReasoning(assistantIdx: number, delta: string) { + const msg = messages.value[assistantIdx]; + if (!msg.reasoning) msg.reasoning = ""; + msg.reasoning += delta; + msg.isThinking = true; + scrollToBottom(); +} + +const expandedReasoning = ref>(new Set()); + +function toggleReasoning(index: number) { + const next = new Set(expandedReasoning.value); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + expandedReasoning.value = next; +} + function openSettings() { tempProvider.value = settings.aiConfig.provider; tempApiKey.value = settings.aiConfig.apiKey; @@ -243,10 +267,14 @@ async function send() { context, }, history, (delta) => { appendAssistantDelta(assistantIdx, delta); - }, sessionId); + }, sessionId, (reasoningDelta) => { + appendAssistantReasoning(assistantIdx, reasoningDelta); + }); } catch (e: any) { messages.value[assistantIdx].content = `Error: ${e.message || e}`; } finally { + const msg = messages.value[assistantIdx]; + if (msg) msg.isThinking = false; isGenerating.value = false; activeAction.value = "generate"; currentSessionId.value = ""; @@ -291,7 +319,7 @@ async function persistConversation() { 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 })), + messages: messages.value.map((m) => ({ role: m.role, content: m.content, ...(m.reasoning ? { reasoning: m.reasoning } : {}) })), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }).catch(() => {}); @@ -307,6 +335,7 @@ function selectConversation(conv: AiConversation) { messages.value = conv.messages.map((m) => ({ role: m.role as "user" | "assistant", content: m.content, + reasoning: m.reasoning, })); showConversationList.value = false; scrollToBottom(); @@ -433,8 +462,30 @@ function formatInlineText(text: string): string { -
+
+
+ +
+
{{ msg.reasoning }}
+
+