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;
|
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 {
|
location = /summary {
|
||||||
try_files /summary.html =404;
|
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 { SpinLoading, Empty, Toast, Popup, Input, Button } from 'antd-mobile'
|
||||||
import { SummaryCard, SummaryItem } from './components/SummaryCard'
|
import { SummaryCard, SummaryItem } from './components/SummaryCard'
|
||||||
import { ChatMessageList, ChatMessage } from './components/ChatMessageList'
|
import { ChatMessageList, ChatMessage } from './components/ChatMessageList'
|
||||||
@ -8,6 +8,63 @@ const CORPID = 'wwd483c2fba24ae30a'
|
|||||||
const AGENTID = '1000021'
|
const AGENTID = '1000021'
|
||||||
const REDIRECT_URI = encodeURIComponent('https://ai.9artedu.com/sidebar/summary.html')
|
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() {
|
function SummaryApp() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [list, setList] = useState<SummaryItem[]>([])
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 60, textAlign: 'center' }}>
|
<div style={{ padding: 60, textAlign: 'center' }}>
|
||||||
@ -355,6 +517,77 @@ function SummaryApp() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ? (
|
{list.length === 0 ? (
|
||||||
chatMessages.length > 0 ? (
|
chatMessages.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -11,6 +11,11 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: true
|
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