소스 검색

新增全局AI小助理

liuziting 7 달 전
부모
커밋
3c31ea02a7
7개의 변경된 파일503개의 추가작업 그리고 10개의 파일을 삭제
  1. 54 0
      package-lock.json
  2. 1 0
      package.json
  3. 3 1
      src/App.vue
  4. 330 0
      src/components/FloatingChefAssistant.vue
  5. 13 5
      src/components/GlobalNavigation.vue
  6. 9 0
      src/env.d.ts
  7. 93 4
      src/services/aiService.ts

+ 54 - 0
package-lock.json

@@ -10,6 +10,7 @@
             "dependencies": {
                 "@vueuse/core": "^10.7.0",
                 "axios": "^1.6.0",
+                "markdown-it": "^14.1.0",
                 "party-js": "^2.2.0",
                 "vue": "^3.4.0",
                 "vue-router": "^4.2.0"
@@ -1202,6 +1203,12 @@
             "dev": true,
             "license": "MIT"
         },
+        "node_modules/argparse": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+            "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+            "license": "Python-2.0"
+        },
         "node_modules/asynckit": {
             "version": "0.4.0",
             "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2076,6 +2083,15 @@
             "dev": true,
             "license": "MIT"
         },
+        "node_modules/linkify-it": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+            "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+            "license": "MIT",
+            "dependencies": {
+                "uc.micro": "^2.0.0"
+            }
+        },
         "node_modules/lru-cache": {
             "version": "10.4.3",
             "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
@@ -2092,6 +2108,23 @@
                 "@jridgewell/sourcemap-codec": "^1.5.0"
             }
         },
+        "node_modules/markdown-it": {
+            "version": "14.1.0",
+            "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
+            "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
+            "license": "MIT",
+            "dependencies": {
+                "argparse": "^2.0.1",
+                "entities": "^4.4.0",
+                "linkify-it": "^5.0.0",
+                "mdurl": "^2.0.0",
+                "punycode.js": "^2.3.1",
+                "uc.micro": "^2.1.0"
+            },
+            "bin": {
+                "markdown-it": "bin/markdown-it.mjs"
+            }
+        },
         "node_modules/math-intrinsics": {
             "version": "1.1.0",
             "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2101,6 +2134,12 @@
                 "node": ">= 0.4"
             }
         },
+        "node_modules/mdurl": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+            "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+            "license": "MIT"
+        },
         "node_modules/merge2": {
             "version": "1.4.1",
             "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2504,6 +2543,15 @@
             "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
             "license": "MIT"
         },
+        "node_modules/punycode.js": {
+            "version": "2.3.1",
+            "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+            "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+            "license": "MIT",
+            "engines": {
+                "node": ">=6"
+            }
+        },
         "node_modules/queue-microtask": {
             "version": "1.2.3",
             "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -2937,6 +2985,12 @@
                 "node": ">=14.17"
             }
         },
+        "node_modules/uc.micro": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+            "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+            "license": "MIT"
+        },
         "node_modules/undici-types": {
             "version": "6.21.0",
             "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",

+ 1 - 0
package.json

@@ -13,6 +13,7 @@
     "dependencies": {
         "@vueuse/core": "^10.7.0",
         "axios": "^1.6.0",
+        "markdown-it": "^14.1.0",
         "party-js": "^2.2.0",
         "vue": "^3.4.0",
         "vue-router": "^4.2.0"

+ 3 - 1
src/App.vue

@@ -1,9 +1,11 @@
 <template>
     <div id="app" class="min-h-screen">
         <router-view />
+        <FloatingChefAssistant />
     </div>
 </template>
 
-<script setup lang="ts">
+<script setup>
 // App组件
+import FloatingChefAssistant from './components/FloatingChefAssistant.vue'
 </script>

+ 330 - 0
src/components/FloatingChefAssistant.vue

@@ -0,0 +1,330 @@
+<template>
+    <div>
+        <!-- 悬浮按钮 -->
+        <button
+            class="fixed bottom-6 right-6 z-40 w-14 h-14 rounded-full bg-gradient-to-br from-yellow-400 to-orange-500 border-2 border-[#0A0910] shadow-xl flex items-center justify-center hover:scale-105 transition-transform md:w-16 md:h-16"
+            @click="toggleOpen"
+            :aria-label="isOpen ? '关闭厨神助理' : '打开厨神助理'"
+        >
+            <div class="text-2xl md:text-3xl relative">
+                <span :class="{ 'opacity-0': isLoading }">👨‍🍳</span>
+                <span v-if="isLoading" class="absolute inset-0 flex items-center justify-center">
+                    <span class="animate-pulse">🤔</span>
+                </span>
+            </div>
+        </button>
+
+        <!-- 遮罩层 -->
+        <div v-if="isOpen" class="fixed inset-0 z-30 bg-black/40 backdrop-blur-[1px]" @click="toggleOpen" />
+
+        <!-- 对话框 -->
+        <div
+            v-if="isOpen"
+            class="fixed z-40 bg-white border-2 border-[#0A0910] md:rounded-xl shadow-2xl overflow-hidden flex flex-col"
+            :class="{
+                'bottom-24 right-6 w-[26rem] sm:w-[32rem] max-h-[80vh]': !isMobile,
+                'inset-0 h-screen w-screen rounded-none': isMobile
+            }"
+        >
+            <!-- 头部 -->
+            <div class="px-4 py-3 bg-yellow-100 border-b-2 border-[#0A0910] flex items-center justify-between shrink-0">
+                <div class="flex items-center gap-2">
+                    <span class="text-xl">👨‍🍳</span>
+                    <div>
+                        <div class="text-sm font-black text-gray-800">厨神助理</div>
+                        <div class="text-xs text-gray-600">会做饭的AI,问我任何烹饪问题~</div>
+                    </div>
+                </div>
+                <button class="p-1 text-gray-600 hover:text-gray-900" @click="toggleOpen">✕</button>
+            </div>
+
+            <!-- 消息列表 -->
+            <div ref="scrollContainer" class="flex-1 overflow-y-auto p-3 space-y-3 bg-gray-50" :class="{ 'min-h-[60vh] md:min-h-[60vh]': messages.length <= 1 && !isMobile }">
+                <div v-for="(m, idx) in messages" :key="idx" class="flex" :class="m.role === 'user' ? 'justify-end' : 'justify-start'">
+                    <div v-if="m.role === 'assistant'" class="max-w-[80%] rounded-lg px-3 py-2 text-sm leading-6 bg-white border-2 border-[#0A0910] text-gray-800 markdown-body">
+                        <div v-if="isLoading && idx === messages.length - 1">
+                            <span class="animate-pulse">👨‍🍳</span>
+                            <span class="text-sm font-medium">大厨正在思考中···</span>
+                        </div>
+                        <div v-else v-html="renderMarkdown(m.content)" />
+                    </div>
+                    <div v-else class="max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap leading-6 bg-yellow-300 border-2 border-[#0A0910] text-gray-900">
+                        {{ m.content }}
+                    </div>
+                </div>
+            </div>
+
+            <!-- 输入区 -->
+            <form class="border-t-2 border-gray-200 p-2 bg-white shrink-0" @submit.prevent="handleSend">
+                <div class="flex items-end gap-2">
+                    <textarea
+                        v-model="input"
+                        class="flex-1 resize-none h-12 max-h-36 px-3 py-2 text-sm border-2 border-[#0A0910] rounded-lg focus:outline-none focus:ring-2 focus:ring-yellow-400"
+                        placeholder="想吃啥?怎么做?食材替换?来问我~"
+                        :disabled="isLoading"
+                        @keydown.enter.exact.prevent="handleSend"
+                    />
+                    <button
+                        type="submit"
+                        class="px-3 py-2 text-sm font-bold rounded-lg border-2 border-[#0A0910] bg-yellow-400 text-gray-900 hover:brightness-95 disabled:opacity-60"
+                        :disabled="!input.trim() || isLoading"
+                    >
+                        发送
+                    </button>
+                </div>
+            </form>
+        </div>
+    </div>
+</template>
+
+<script setup>
+import { ref, nextTick, onMounted } from 'vue'
+import { chatStream } from '@/services/aiService'
+import MarkdownIt from 'markdown-it'
+
+const isOpen = ref(false)
+const isMobile = ref(false)
+
+onMounted(() => {
+    // 检测移动设备
+    const checkMobile = () => {
+        isMobile.value = window.innerWidth < 768
+    }
+    checkMobile()
+    window.addEventListener('resize', checkMobile)
+})
+const messages = ref([{ role: 'assistant', content: '你好,我是你的厨神助理!告诉我你有什么食材/口味/菜名,我来帮你出招~' }])
+const input = ref('')
+const isLoading = ref(false)
+const scrollContainer = ref(null)
+
+const toggleOpen = () => {
+    isOpen.value = !isOpen.value
+    if (isOpen.value) {
+        nextTick(scrollToBottom)
+    }
+}
+
+const scrollToBottom = () => {
+    if (!scrollContainer.value) return
+    const container = scrollContainer.value
+    // 使用平滑滚动并确保完全滚动到底部
+    container.scrollTo({
+        top: container.scrollHeight,
+        behavior: 'smooth'
+    })
+}
+
+const handleSend = async () => {
+    const text = input.value.trim()
+    if (!text || isLoading.value) return
+
+    // 先添加消息再滚动
+    messages.value.push({ role: 'user', content: text })
+    input.value = ''
+    isLoading.value = true
+    // 确保DOM更新后滚动
+    await nextTick()
+    scrollToBottom()
+
+    try {
+        const history = buildHistory()
+        messages.value.push({ role: 'assistant', content: '' })
+        let firstChunk = true
+        await chatStream(history, delta => {
+            if (firstChunk) {
+                isLoading.value = false
+                firstChunk = false
+            }
+            // 直接存储原始数据,在渲染时统一解码
+            messages.value[messages.value.length - 1].content += delta
+            // 使用requestAnimationFrame优化滚动性能
+            requestAnimationFrame(scrollToBottom)
+        })
+    } catch (e) {
+        isLoading.value = false
+    }
+}
+
+const buildHistory = () => {
+    const systemPrompt = {
+        role: 'system',
+        content: '你是一位专业而风趣的中华料理大师与厨房助理。简洁友好地回答与烹饪、食材替代、口味调配、厨具与火候、餐酒搭配相关的问题。尽量用要点列举,必要时给出分步操作。'
+    }
+    return [systemPrompt, ...messages.value.filter(m => m.role !== 'system')]
+}
+
+// 简易Markdown渲染(安全转义 + 常用语法)
+const escapeHtml = str => {
+    return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
+}
+
+const renderMarkdown = md => {
+    if (!md) return ''
+    // 先转义所有HTML标签
+    let escapedMd = escapeHtml(md)
+    // 将转义后的<br>标签恢复为换行符
+    escapedMd = escapedMd.replace(/<br\s*\/?>/gi, '\n')
+    const lines = escapedMd.split('\n')
+    let html = ''
+    let inList = false
+    let inCode = false
+    let codeBuffer = []
+    let paraBuffer = []
+
+    const flushParagraph = () => {
+        if (paraBuffer.length > 0) {
+            const text = paraBuffer.join('\n')
+            html += `<p>${inline(text)}</p>`
+            paraBuffer = []
+        }
+    }
+
+    const flushList = () => {
+        if (inList) {
+            html += '</ul>'
+            inList = false
+        }
+    }
+
+    const inline = s => {
+        let t = escapeHtml(s)
+        // inline code
+        t = t.replace(/`([^`]+)`/g, '<code>$1</code>')
+        // bold **text** or __text__
+        t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
+        t = t.replace(/__([^_]+)__/g, '<strong>$1</strong>')
+        // italic *text* or _text_
+        t = t.replace(/(^|\W)\*([^*]+)\*(?=\W|$)/g, '$1<em>$2</em>')
+        t = t.replace(/(^|\W)_([^_]+)_(?=\W|$)/g, '$1<em>$2</em>')
+        return t
+    }
+
+    for (const raw of lines) {
+        const line = raw.replace(/\r$/, '')
+
+        // fenced code
+        if (line.trim().startsWith('```')) {
+            if (!inCode) {
+                flushParagraph()
+                flushList()
+                inCode = true
+                codeBuffer = []
+            } else {
+                const codeHtml = escapeHtml(codeBuffer.join('\n'))
+                html += `<pre class="md-pre"><code>${codeHtml}</code></pre>`
+                inCode = false
+                codeBuffer = []
+            }
+            continue
+        }
+        if (inCode) {
+            codeBuffer.push(line)
+            continue
+        }
+
+        // empty line => flush blocks
+        if (line.trim() === '') {
+            flushParagraph()
+            flushList()
+            continue
+        }
+
+        // headings
+        if (/^###\s+/.test(line)) {
+            flushParagraph()
+            flushList()
+            html += `<h3>${inline(line.replace(/^###\s+/, ''))}</h3>`
+            continue
+        }
+        if (/^##\s+/.test(line)) {
+            flushParagraph()
+            flushList()
+            html += `<h2>${inline(line.replace(/^##\s+/, ''))}</h2>`
+            continue
+        }
+        if (/^#\s+/.test(line)) {
+            flushParagraph()
+            flushList()
+            html += `<h1>${inline(line.replace(/^#\s+/, ''))}</h1>`
+            continue
+        }
+
+        // list item
+        if (/^\s*[-*]\s+/.test(line)) {
+            flushParagraph()
+            if (!inList) {
+                inList = true
+                html += '<ul>'
+            }
+            html += `<li>${inline(line.replace(/^\s*[-*]\s+/, ''))}</li>`
+            continue
+        }
+
+        // normal paragraph content
+        paraBuffer.push(line)
+    }
+
+    // flush tail
+    if (inCode) {
+        const codeHtml = escapeHtml(codeBuffer.join('\n'))
+        html += `<pre class="md-pre"><code>${codeHtml}</code></pre>`
+    }
+    flushParagraph()
+    flushList()
+
+    return html
+}
+</script>
+
+<style scoped>
+/* 额外小样式:优化滚动条 */
+::-webkit-scrollbar {
+    width: 8px;
+}
+::-webkit-scrollbar-thumb {
+    background: #e5e7eb;
+    border-radius: 6px;
+}
+
+/* markdown基础样式 */
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3 {
+    font-weight: 800;
+    margin: 0.25rem 0 0.25rem;
+}
+.markdown-body h1 {
+    font-size: 1.1rem;
+}
+.markdown-body h2 {
+    font-size: 1.05rem;
+}
+.markdown-body h3 {
+    font-size: 1rem;
+}
+.markdown-body p {
+    margin: 0.25rem 0;
+}
+.markdown-body ul {
+    margin: 0.25rem 0 0.25rem 1rem;
+    list-style: disc;
+}
+.markdown-body li {
+    margin: 0.125rem 0;
+}
+.markdown-body code {
+    background: #f3f4f6;
+    padding: 0.1rem 0.25rem;
+    border-radius: 4px;
+    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
+}
+.md-pre {
+    background: #111827;
+    color: #e5e7eb;
+    padding: 0.5rem 0.75rem;
+    border-radius: 0.5rem;
+    overflow: auto;
+    border: 1px solid #0a0910;
+}
+</style>

+ 13 - 5
src/components/GlobalNavigation.vue

@@ -46,13 +46,21 @@
                         <span>🍽️</span>
                         <span>满汉全席</span>
                     </router-link>
-                    <router-link
+                    <!-- <router-link
                         to="/how-to-cook"
                         class="flex items-center gap-1 px-3 py-2 rounded-lg font-bold border-2 border-[#0A0910] transition-all duration-200 transform hover:scale-105 text-sm"
                         :class="$route.path === '/how-to-cook' ? 'bg-yellow-400 text-gray-800' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
                     >
                         <span>🍳</span>
                         <span>厨神秘籍</span>
+                    </router-link> -->
+                    <router-link
+                        to="/fortune-cooking"
+                        class="flex items-center gap-1 px-3 py-2 rounded-lg font-bold border-2 border-[#0A0910] transition-all duration-200 transform hover:scale-105 text-sm"
+                        :class="$route.path === '/fortune-cooking' ? 'bg-yellow-400 text-gray-800' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
+                    >
+                        <span>🔮</span>
+                        <span>玄学厨房</span>
                     </router-link>
                     <router-link
                         to="/sauce-design"
@@ -83,7 +91,7 @@
                             @mouseenter="handleMouseEnter"
                             class="absolute right-0 top-full mt-0.5 w-40 bg-white border-2 border-[#0A0910] rounded-lg shadow-lg z-50 overflow-hidden"
                         >
-                            <router-link
+                            <!-- <router-link
                                 to="/fortune-cooking"
                                 @click="showMoreMenu = false"
                                 class="flex items-center gap-2 px-4 py-3 text-sm font-bold transition-colors duration-200 hover:bg-gray-100"
@@ -91,7 +99,7 @@
                             >
                                 <span>🔮</span>
                                 <span>玄学厨房</span>
-                            </router-link>
+                            </router-link> -->
                             <router-link
                                 to="/favorites"
                                 @click="showMoreMenu = false"
@@ -178,7 +186,7 @@
                         <span>🍽️</span>
                         <span>满汉全席</span>
                     </router-link>
-                    <router-link
+                    <!-- <router-link
                         to="/how-to-cook"
                         @click="showMobileMenu = false"
                         class="flex items-center gap-2 w-full px-3 py-2 rounded-lg font-bold border-2 border-[#0A0910] transition-all duration-200 text-sm"
@@ -186,7 +194,7 @@
                     >
                         <span>🍳</span>
                         <span>厨神秘籍</span>
-                    </router-link>
+                    </router-link> -->
                     <router-link
                         to="/sauce-design"
                         @click="showMobileMenu = false"

+ 9 - 0
src/env.d.ts

@@ -0,0 +1,9 @@
+/// <reference types="vite/client" />
+
+declare module '*.vue' {
+	import type { DefineComponent } from 'vue'
+	const component: DefineComponent<{}, {}, any>
+	export default component
+}
+
+

+ 93 - 4
src/services/aiService.ts

@@ -175,12 +175,16 @@ export const generateTableMenu = async (config: {
             japanese: '日式为主'
         }
 
+        const styleText = (styleMap as Record<string, string>)[config.cuisineStyle] || '混合菜系'
+        const sceneText = (sceneMap as Record<string, string>)[config.diningScene] || '家庭聚餐'
+        const nutritionText = (nutritionMap as Record<string, string>)[config.nutritionFocus] || '营养均衡'
+
         let prompt = `请为我设计一桌菜,要求如下:
 - ${config.flexibleCount ? `参考菜品数量:${config.dishCount}道菜(可以根据实际情况智能调整,重点是搭配合理)` : `菜品数量:${config.dishCount}道菜(请严格按照这个数量生成)`}
 - 口味偏好:${tasteText}
-- 菜系风格:${styleMap[config.cuisineStyle] || '混合菜系'}
-- 用餐场景:${sceneMap[config.diningScene] || '家庭聚餐'}
-- 营养搭配:${nutritionMap[config.nutritionFocus] || '营养均衡'}`
+- 菜系风格:${styleText}
+- 用餐场景:${sceneText}
+- 营养搭配:${nutritionText}`
 
         if (config.customDishes.length > 0) {
             prompt += `\n- ${config.flexibleCount ? '优先考虑的菜品' : '必须包含的菜品'}:${config.customDishes.join('、')}${config.flexibleCount ? '(可以作为参考,根据搭配需要决定是否全部包含)' : '(请确保这些菜品都包含在菜单中)'
@@ -946,7 +950,7 @@ export const recommendSauces = async (preferences: SaucePreference): Promise<str
             hotpot: '火锅'
         }
 
-        const useCaseText = preferences.useCase.map(uc => useCaseMap[uc] || uc).join('、')
+        const useCaseText = preferences.useCase.map(uc => (useCaseMap as Record<string, string>)[uc] || uc).join('、')
         const ingredientsText = preferences.availableIngredients.length > 0
             ? preferences.availableIngredients.join('、')
             : '无特殊要求'
@@ -1506,3 +1510,88 @@ export const generateNumberFortune = async (params: NumberFortuneParams): Promis
 
 // 导出配置更新函数,供外部使用
 export { AI_CONFIG }
+
+/**
+ * 通用聊天(流式)
+ * @param messages 对话历史
+ * @param onDelta 每次增量文本回调
+ * @param onComplete 结束回调(携带完整文本)
+ * @param onError 错误回调
+ */
+export const chatStream = async (
+    messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
+    onDelta: (deltaText: string) => void,
+    onComplete?: (fullText: string) => void,
+    onError?: (err: unknown) => void
+): Promise<void> => {
+    const url = AI_CONFIG.baseURL.replace(/\/$/, '') + '/chat/completions'
+    const headers: Record<string, string> = {
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${AI_CONFIG.apiKey}`
+    }
+
+    const body = JSON.stringify({
+        model: AI_CONFIG.model,
+        messages,
+        temperature: AI_CONFIG.temperature,
+        stream: true
+    })
+
+    try {
+        const response = await fetch(url, {
+            method: 'POST',
+            headers,
+            body
+        })
+
+        if (!response.ok || !response.body) {
+            throw new Error(`请求失败: ${response.status}`)
+        }
+
+        const reader = response.body.getReader()
+        const decoder = new TextDecoder('utf-8')
+        let buffer = ''
+        let fullText = ''
+
+        while (true) {
+            const { value, done } = await reader.read()
+            if (done) break
+            buffer += decoder.decode(value, { stream: true })
+
+            // 以SSE事件为单位切分
+            const parts = buffer.split(/\n\n/)
+            buffer = parts.pop() || ''
+
+            for (const part of parts) {
+                const lines = part.split('\n').map(l => l.trim()).filter(Boolean)
+                for (const line of lines) {
+                    if (!line.startsWith('data:')) continue
+                    const data = line.slice(5).trim()
+                    if (data === '[DONE]') {
+                        if (onComplete) onComplete(fullText)
+                        return
+                    }
+                    try {
+                        const json = JSON.parse(data)
+                        // 兼容OpenAI/同构流式规范
+                        const delta = json.choices?.[0]?.delta?.content ?? json.choices?.[0]?.message?.content ?? ''
+                        if (delta) {
+                            fullText += delta
+                            onDelta(delta)
+                        }
+                    } catch (e) {
+                        // 非JSON行,忽略
+                        continue
+                    }
+                }
+            }
+        }
+
+        // 读流结束但未收到DONE
+        if (onComplete) onComplete(fullText)
+    } catch (err) {
+        if (onError) onError(err)
+        else console.error('chatStream error:', err)
+        throw err
+    }
+}