feat(ai): 添加流式数据处理和执行 SQL 功能
This commit is contained in:
parent
9e7ed096b7
commit
1e15249da9
|
|
@ -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::<serde_json::Value>(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::<serde_json::Value>(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<String> {
|
|||
.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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1052,11 +1052,12 @@ async function setupFileDrop() {
|
|||
</Pane>
|
||||
|
||||
<Pane v-if="showAiPanel" :size="aiPanelSize" :min-size="15" :max-size="40">
|
||||
<div class="h-full bg-background">
|
||||
<div class="h-full min-h-0 overflow-hidden bg-background">
|
||||
<AiAssistant
|
||||
:tab="activeTab"
|
||||
:connection="activeConnection"
|
||||
@replace-sql="replaceActiveSql"
|
||||
@execute-sql="tryExecute"
|
||||
@close="toggleAiPanel"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string[]>([]);
|
||||
|
||||
|
|
@ -103,6 +109,11 @@ const providerDefaults: Record<AiProvider, { endpoint: string; model: string }>
|
|||
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 {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<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>
|
||||
|
|
@ -237,11 +265,11 @@ function formatInlineText(text: string): string {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="messages.length === 0" class="flex-1 flex flex-col items-center justify-center text-center text-muted-foreground">
|
||||
<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>
|
||||
</div>
|
||||
<ScrollArea v-else ref="scrollRef" class="flex-1">
|
||||
<ScrollArea v-else ref="scrollRef" class="min-h-0 flex-1 overflow-hidden">
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
|
||||
<template v-for="(msg, i) in messages" :key="i">
|
||||
|
|
@ -251,33 +279,38 @@ function formatInlineText(text: string): string {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<div class="max-w-[95%] text-xs leading-relaxed">
|
||||
<div v-else class="flex">
|
||||
<div class="max-w-[95%] rounded-lg bg-muted px-3 py-2 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">
|
||||
<div v-if="seg.type === 'text'" class="whitespace-normal">
|
||||
<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 v-else class="my-2 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 class="flex items-center gap-1.5">
|
||||
<button class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200" :title="t('ai.executeSql')" @click="executeSql(seg.content)">
|
||||
<Play class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200" :title="t('ai.apply')" @click="applySql(seg.content)">
|
||||
<Replace class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button class="rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200" :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>
|
||||
</div>
|
||||
<pre class="p-3 text-xs leading-relaxed overflow-x-auto text-zinc-100"><code>{{ seg.content }}</code></pre>
|
||||
<pre class="whitespace-pre-wrap break-words p-3 text-xs leading-relaxed text-zinc-100"><code>{{ seg.content }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isGenerating" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div v-if="isWaitingForFirstDelta" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
<span>{{ t('ai.thinking') }}</span>
|
||||
</div>
|
||||
|
|
@ -299,7 +332,7 @@ function formatInlineText(text: string): string {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<template v-if="connection">
|
||||
<span class="text-foreground/25">/</span>
|
||||
<Database class="h-3 w-3 shrink-0 text-foreground/40" />
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const delegatedProps = reactiveOmit(props, 'class')
|
|||
>
|
||||
<ScrollAreaViewport
|
||||
data-slot="scroll-area-viewport"
|
||||
class="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
class="size-full overflow-auto rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
<slot />
|
||||
</ScrollAreaViewport>
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ export default {
|
|||
noSql: "No SQL to process",
|
||||
sqlPreview: "SQL Preview",
|
||||
copySql: "Copy SQL",
|
||||
executeSql: "Execute SQL",
|
||||
copyAll: "Copy All",
|
||||
copied: "Copied",
|
||||
replace: "Replace Editor",
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ export default {
|
|||
noSql: "当前没有 SQL 可处理",
|
||||
sqlPreview: "SQL 预览",
|
||||
copySql: "复制 SQL",
|
||||
executeSql: "立即执行",
|
||||
copyAll: "复制全部",
|
||||
copied: "已复制",
|
||||
replace: "替换编辑器",
|
||||
|
|
|
|||
Loading…
Reference in New Issue