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
This commit is contained in:
jqb 2026-08-10 14:12:54 +08:00
parent 166e48cada
commit 72c7d557eb
3 changed files with 249 additions and 1 deletions

View File

@ -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;

View File

@ -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<SummaryItem[]>([])
@ -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<number | null>(null)
const rtAbortRef = useRef<AbortController | null>(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 (
<div style={{ padding: 60, textAlign: 'center' }}>
@ -355,6 +517,77 @@ function SummaryApp() {
</button>
</div>
{/* 立即总结(实时流式) */}
<div style={{
background: '#fff',
borderRadius: 12,
padding: '14px 16px',
marginBottom: 14,
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
}}>
<Button
block
color="primary"
loading={rtStreaming}
onClick={startRealTimeSummary}
style={{ borderRadius: 8 }}
>
{rtStreaming ? '正在实时总结...' : '⚡ 立即总结'}
</Button>
{(rtStreaming || rtText || rtError) && (
<div style={{ marginTop: 12 }}>
{rtError ? (
<div style={{
background: '#fff2f0',
border: '1px solid #ffccc7',
borderRadius: 8,
padding: '10px 12px',
color: '#cf1322',
fontSize: 13
}}>
⚠️ {rtError}
</div>
) : (
<>
<div style={{
fontSize: 14,
lineHeight: 1.8,
color: '#333',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
background: '#f7f8fa',
borderRadius: 8,
padding: '12px',
minHeight: 48
}}>
{rtText || <span style={{ color: '#999' }}>AI 正在生成总结...</span>}
{rtStreaming && rtText && <span className="rt-blink-cursor">|</span>}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 10 }}>
<span style={{ fontSize: 11, color: rtDone ? '#52c41a' : '#1677ff' }}>
{rtDone ? '✓ 已完成' : '生成中...'}
</span>
<Button size="small" fill="outline" disabled={!rtText} onClick={copyRtText}>
复制
</Button>
</div>
</>
)}
</div>
)}
<style>{`
.rt-blink-cursor {
animation: rtBlink 1s step-end infinite;
color: #1677ff;
font-weight: bold;
}
@keyframes rtBlink {
50% { opacity: 0; }
}
`}</style>
</div>
{list.length === 0 ? (
chatMessages.length > 0 ? (
<>

View File

@ -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')
}
}
},