From 72c7d557eb22e76a222e62f93f5be78269468583 Mon Sep 17 00:00:00 2001 From: jqb Date: Mon, 10 Aug 2026 14:12:54 +0800 Subject: [PATCH] feat(sidebar): add real-time streaming summary feature 1. add proxy config for summary API in vite config and nginx 2. implement SSE streaming parsing and text extraction utilities 3. add real-time summary component with typing animation and copy function --- frontend/sidebar/nginx.conf | 10 ++ frontend/sidebar/src/SummaryApp.tsx | 235 +++++++++++++++++++++++++++- frontend/sidebar/vite.config.ts | 5 + 3 files changed, 249 insertions(+), 1 deletion(-) diff --git a/frontend/sidebar/nginx.conf b/frontend/sidebar/nginx.conf index 54d1f98..d14157a 100644 --- a/frontend/sidebar/nginx.conf +++ b/frontend/sidebar/nginx.conf @@ -12,6 +12,16 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + # 实时总结接口(外部 AI 总结服务,支持流式) + location /summary-api/ { + proxy_pass http://8.159.132.53:8000/api/summary/; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 300s; + } + # 每日总结独立页面 location = /summary { try_files /summary.html =404; diff --git a/frontend/sidebar/src/SummaryApp.tsx b/frontend/sidebar/src/SummaryApp.tsx index 2bda35b..ceda2d1 100644 --- a/frontend/sidebar/src/SummaryApp.tsx +++ b/frontend/sidebar/src/SummaryApp.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { SpinLoading, Empty, Toast, Popup, Input, Button } from 'antd-mobile' import { SummaryCard, SummaryItem } from './components/SummaryCard' import { ChatMessageList, ChatMessage } from './components/ChatMessageList' @@ -8,6 +8,63 @@ const CORPID = 'wwd483c2fba24ae30a' const AGENTID = '1000021' const REDIRECT_URI = encodeURIComponent('https://ai.9artedu.com/sidebar/summary.html') +// 反转义 JSON 字符串中的转义序列(用于流式中途展示未完结的 JSON 字符串) +const unescapeJsonStr = (s: string): string => + s.replace(/\\(u[0-9a-fA-F]{4}|n|t|r|b|f|"|\\|\/)/g, (m, p) => { + if (p.length > 1) return String.fromCharCode(parseInt(p.slice(1), 16)) + switch (p) { + case 'n': return '\n' + case 't': return '\t' + case 'r': return '\r' + case 'b': return '\b' + case 'f': return '\f' + case '"': return '"' + case '\\': return '\\' + case '/': return '/' + default: return m + } + }) + +// 从累计的原始报文中提取用于展示的正文: +// 完整 JSON 字符串 → 解析;未完结的 JSON 字符串(流式中途)→ 截尾反转义; +// data: 行(SSE)→ 拼接;其它 → 原文 +const extractSummaryText = (raw: string): string => { + const t = raw.trimStart() + if (!t) return '' + if (t.startsWith('data:') || t.includes('\ndata:')) { + return t.split('\n') + .filter(l => l.startsWith('data:')) + .map(l => l.slice(5).trimStart()) + .filter(l => l && l !== '[DONE]') + .map(l => { + try { + const v = JSON.parse(l) + return typeof v === 'string' ? v : (v.text || v.content || v.summary || '') + } catch { + return l + } + }) + .join('') + } + if (t.startsWith('"')) { + try { + const v = JSON.parse(t) + return typeof v === 'string' ? v : JSON.stringify(v) + } catch { + const inner = t.slice(1) + .replace(/\\u[0-9a-fA-F]{0,3}$/, '') + .replace(/\\$/, '') + return unescapeJsonStr(inner) + } + } + try { + const v = JSON.parse(t) + if (typeof v === 'string') return v + if (v && typeof v === 'object') return v.summary || v.text || v.message || JSON.stringify(v) + } catch { /* 非 JSON,按原文展示 */ } + return t +} + function SummaryApp() { const [loading, setLoading] = useState(true) const [list, setList] = useState([]) @@ -277,6 +334,111 @@ function SummaryApp() { } } + // ===== 立即总结(实时流式) ===== + const [rtStreaming, setRtStreaming] = useState(false) + const [rtText, setRtText] = useState('') + const [rtDone, setRtDone] = useState(false) + const [rtError, setRtError] = useState('') + const rtFullRef = useRef('') // 已接收的完整正文 + const rtShownRef = useRef('') // 已展示到屏幕的正文(打字机效果) + const rtEndedRef = useRef(false) // 流是否结束 + const rtTimerRef = useRef(null) + const rtAbortRef = useRef(null) + + const stopRtTimer = () => { + if (rtTimerRef.current != null) { + clearInterval(rtTimerRef.current) + rtTimerRef.current = null + } + } + + // 逐字吐出已接收内容;流结束且吐完后置为完成 + const startRtTimer = () => { + if (rtTimerRef.current != null) return + rtTimerRef.current = window.setInterval(() => { + const full = rtFullRef.current + if (rtShownRef.current.length < full.length) { + rtShownRef.current = full.slice(0, rtShownRef.current.length + 3) + setRtText(rtShownRef.current) + } else if (rtEndedRef.current) { + stopRtTimer() + setRtStreaming(false) + setRtDone(true) + } + }, 30) + } + + useEffect(() => { + return () => { + rtAbortRef.current?.abort() + stopRtTimer() + } + }, []) + + const startRealTimeSummary = async () => { + const staffId = localStorage.getItem('staff_user_id') || '' + if (!customerId || !staffId) { + Toast.show({ content: '缺少客户或员工信息,无法总结', icon: 'fail' }) + return + } + + rtAbortRef.current?.abort() + const controller = new AbortController() + rtAbortRef.current = controller + rtFullRef.current = '' + rtShownRef.current = '' + rtEndedRef.current = false + stopRtTimer() + setRtText('') + setRtDone(false) + setRtError('') + setRtStreaming(true) + startRtTimer() + + try { + const url = `/sidebar/summary-api/real-time?from_user=${encodeURIComponent(customerId)}&to_user=${encodeURIComponent(staffId)}` + const res = await fetch(url, { signal: controller.signal }) + if (!res.ok) { + throw new Error(`接口返回 ${res.status}`) + } + if (!res.body) { + rtFullRef.current = extractSummaryText(await res.text()) + rtEndedRef.current = true + } else { + const reader = res.body.getReader() + const decoder = new TextDecoder('utf-8') + let raw = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + raw += decoder.decode(value, { stream: true }) + rtFullRef.current = extractSummaryText(raw) + } + raw += decoder.decode() + rtFullRef.current = extractSummaryText(raw) + rtEndedRef.current = true + } + if (!rtFullRef.current.trim()) { + stopRtTimer() + setRtStreaming(false) + setRtError('接口未返回总结内容') + } + } catch (e: any) { + if (e?.name === 'AbortError') return + console.error('实时总结失败:', e) + stopRtTimer() + setRtStreaming(false) + setRtError(e?.message === 'Failed to fetch' ? '网络错误,请重试' : (e?.message || '网络错误,请重试')) + } + } + + const copyRtText = () => { + navigator.clipboard.writeText(rtText).then( + () => Toast.show({ content: '已复制', icon: 'success' }), + () => Toast.show({ content: '复制失败,请长按选择复制', icon: 'fail' }) + ) + } + if (loading) { return (
@@ -355,6 +517,77 @@ function SummaryApp() {
+ {/* 立即总结(实时流式) */} +
+ + + {(rtStreaming || rtText || rtError) && ( +
+ {rtError ? ( +
+ ⚠️ {rtError} +
+ ) : ( + <> +
+ {rtText || AI 正在生成总结...} + {rtStreaming && rtText && |} +
+
+ + {rtDone ? '✓ 已完成' : '生成中...'} + + +
+ + )} +
+ )} + +
+ {list.length === 0 ? ( chatMessages.length > 0 ? ( <> diff --git a/frontend/sidebar/vite.config.ts b/frontend/sidebar/vite.config.ts index 8da2c7b..96c9adb 100644 --- a/frontend/sidebar/vite.config.ts +++ b/frontend/sidebar/vite.config.ts @@ -11,6 +11,11 @@ export default defineConfig({ '/api': { target: 'http://localhost:8080', changeOrigin: true + }, + '/sidebar/summary-api': { + target: 'http://8.159.132.53:8000', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/sidebar\/summary-api/, '/api/summary') } } },