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:
parent
166e48cada
commit
72c7d557eb
@ -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;
|
||||
|
||||
@ -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 ? (
|
||||
<>
|
||||
|
||||
@ -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')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user