Merge remote-tracking branch 'origin/master'

This commit is contained in:
qinyong@9artedu.com 2026-07-31 09:21:42 +08:00
commit 737432cc49
2 changed files with 120 additions and 92 deletions

View File

@ -350,14 +350,17 @@ public class GenerationController {
emitter.onCompletion(() -> generationFuture.cancel(true)); emitter.onCompletion(() -> generationFuture.cancel(true));
emitter.onTimeout(() -> generationFuture.cancel(true)); emitter.onTimeout(() -> generationFuture.cancel(true));
// 等待生成结果,每 10s 发送一次心跳,防止 nginx/移动网络因 30s 空闲超时而断开 // 等待生成结果,每 5s 发送一次心跳,防止 nginx/移动网络因空闲超时而断开
while (!generationFuture.isDone()) { while (!generationFuture.isDone()) {
try { try {
generationFuture.get(10, TimeUnit.SECONDS); generationFuture.get(5, TimeUnit.SECONDS);
break; break;
} catch (TimeoutException e) { } catch (TimeoutException e) {
try { try {
emitter.send(SseEmitter.event().comment("keep-alive")); Map<String, Object> ping = new HashMap<>();
ping.put("type", "ping");
ping.put("time", System.currentTimeMillis());
emitter.send(SseEmitter.event().name("ping").data(ping));
} catch (IOException ioe) { } catch (IOException ioe) {
log.warn("SSE 心跳发送失败,连接已断开"); log.warn("SSE 心跳发送失败,连接已断开");
generationFuture.cancel(true); generationFuture.cancel(true);

View File

@ -35,6 +35,7 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
const [utterances, setUtterances] = useState<UtteranceItem[]>([]) const [utterances, setUtterances] = useState<UtteranceItem[]>([])
const [error, setError] = useState('') const [error, setError] = useState('')
const sseRef = useRef<EventSource | null>(null) const sseRef = useRef<EventSource | null>(null)
const retryCountRef = useRef(0)
const customerId = propCustomerId || localStorage.getItem('current_customer_id') || '' const customerId = propCustomerId || localStorage.getItem('current_customer_id') || ''
const staffId = userInfo?.userId || '' const staffId = userInfo?.userId || ''
@ -103,110 +104,134 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
setGenerating(true) setGenerating(true)
setLoading(false) setLoading(false)
setError('')
retryCountRef.current = 0
try { const attempt = () => {
// 单聊才获取画像标签 try {
if (!isGroupChat && customerId) { // 仅首次连接时获取画像标签,重试时不重复调用
const profileRes = await fetch(`/api/v1/intent/profile/${encodeURIComponent(customerId)}?corpId=${corpId}`) if (retryCountRef.current === 0 && !isGroupChat && customerId) {
const profileData = await profileRes.json() fetch(`/api/v1/intent/profile/${encodeURIComponent(customerId)}?corpId=${corpId}`)
if (profileData.code === 0 && profileData.data) { .then(res => res.json())
const p = profileData.data .then(profileData => {
const tags: string[] = [] if (profileData.code === 0 && profileData.data) {
if (p.currentOccupation) tags.push(p.currentOccupation) const p = profileData.data
if (p.studentType) tags.push(p.studentType) const tags: string[] = []
if (p.skillLevel) tags.push(p.skillLevel) if (p.currentOccupation) tags.push(p.currentOccupation)
if (p.intentLevel) tags.push(p.intentLevel) if (p.studentType) tags.push(p.studentType)
if (p.concernFocus) tags.push(p.concernFocus) if (p.skillLevel) tags.push(p.skillLevel)
if (p.decisionStage) tags.push(p.decisionStage) if (p.intentLevel) tags.push(p.intentLevel)
setProfileTags(tags) if (p.concernFocus) tags.push(p.concernFocus)
if (p.decisionStage) tags.push(p.decisionStage)
setProfileTags(tags)
}
})
.catch(e => console.error('获取学员画像失败:', e))
} }
}
// 构建 SSE URL:群聊传 roomid,单聊传 fromUser+toUser+staffName // 构建 SSE URL:群聊传 roomid,单聊传 fromUser+toUser+staffName
const params = new URLSearchParams() const params = new URLSearchParams()
if (isGroupChat && chatId) { if (isGroupChat && chatId) {
params.append('roomid', chatId) params.append('roomid', chatId)
} else if (customerId && staffId) { } else if (customerId && staffId) {
params.append('fromUser', customerId) params.append('fromUser', customerId)
params.append('toUser', staffId) params.append('toUser', staffId)
} }
if (userInfo?.userName) { if (userInfo?.userName) {
params.append('staffName', userInfo.userName) params.append('staffName', userInfo.userName)
} }
const url = `/api/v1/generation/utterance-stream?${params.toString()}` const url = `/api/v1/generation/utterance-stream?${params.toString()}`
console.log('SSE connect:', url) console.log('SSE connect (attempt', retryCountRef.current + 1, '):', url)
const es = new EventSource(url) const es = new EventSource(url)
sseRef.current = es sseRef.current = es
setUtterances(STYLES.map(s => ({ styleId: s.id, text: '', isComplete: false, isTyping: false }))) setUtterances(STYLES.map(s => ({ styleId: s.id, text: '', isComplete: false, isTyping: false })))
es.addEventListener('start', (event: MessageEvent) => { es.addEventListener('start', (event: MessageEvent) => {
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
if (data.type === 'start') { /* 可处理 styles 信息 */ } if (data.type === 'start') { /* 可处理 styles 信息 */ }
} catch (e) { console.error('SSE start error:', e) } } catch (e) { console.error('SSE start error:', e) }
}) })
es.addEventListener('item-start', (event: MessageEvent) => { es.addEventListener('ping', (_event: MessageEvent) => {
try { // 服务端心跳保活,无需处理
const data = JSON.parse(event.data) })
if (data.type === 'item-start') {
setUtterances(prev => prev.map(u =>
u.styleId === data.styleId ? { ...u, isTyping: true } : u
))
}
} catch (e) { console.error('SSE item-start error:', e) }
})
es.addEventListener('chunk', (event: MessageEvent) => { es.addEventListener('item-start', (event: MessageEvent) => {
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
if (data.type === 'chunk') { if (data.type === 'item-start') {
setUtterances(prev => prev.map(u => setUtterances(prev => prev.map(u =>
u.styleId === data.styleId ? { ...u, text: u.text + data.text } : u u.styleId === data.styleId ? { ...u, isTyping: true } : u
)) ))
} }
} catch (e) { console.error('SSE chunk error:', e) } } catch (e) { console.error('SSE item-start error:', e) }
}) })
es.addEventListener('complete', (event: MessageEvent) => { es.addEventListener('chunk', (event: MessageEvent) => {
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
if (data.type === 'complete') { if (data.type === 'chunk') {
setUtterances(prev => prev.map(u => setUtterances(prev => prev.map(u =>
u.styleId === data.styleId ? { ...u, text: data.fullText, isComplete: true, isTyping: false } : u u.styleId === data.styleId ? { ...u, text: u.text + data.text } : u
)) ))
} }
} catch (e) { console.error('SSE complete error:', e) } } catch (e) { console.error('SSE chunk error:', e) }
}) })
es.addEventListener('done', (event: MessageEvent) => { es.addEventListener('complete', (event: MessageEvent) => {
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
if (data.type === 'done') { if (data.type === 'complete') {
setUtterances(prev => prev.map(u =>
u.styleId === data.styleId ? { ...u, text: data.fullText, isComplete: true, isTyping: false } : u
))
}
} catch (e) { console.error('SSE complete error:', e) }
})
es.addEventListener('done', (event: MessageEvent) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'done') {
setGenerating(false)
es.close()
retryCountRef.current = 0
}
} catch (e) { console.error('SSE done error:', e) }
})
es.addEventListener('error', (event: MessageEvent) => {
es.close()
try {
const data = JSON.parse(event.data)
setError(data.message || '生成失败')
setGenerating(false) setGenerating(false)
es.close() retryCountRef.current = 0
} catch (e) {
// 连接类错误自动重试一次,避免网络抖动导致用户看到"连接中断"
if (retryCountRef.current < 2) {
retryCountRef.current += 1
console.warn(`SSE 连接中断,第 ${retryCountRef.current} 次重试...`)
setTimeout(attempt, 1000)
} else {
setError('连接中断')
setGenerating(false)
retryCountRef.current = 0
}
} }
} catch (e) { console.error('SSE done error:', e) } })
})
es.addEventListener('error', (event: MessageEvent) => { } catch (e) {
try { console.error('生成失败:', e)
const data = JSON.parse(event.data) setError('网络错误,请重试')
setError(data.message || '生成失败')
} catch (e) {
setError('连接中断')
}
setGenerating(false) setGenerating(false)
es.close() }
})
} catch (e) {
console.error('生成失败:', e)
setError('网络错误,请重试')
setGenerating(false)
} }
attempt()
}, [canStartGeneration, isGroupChat, customerId, staffId, chatId, corpId]) }, [canStartGeneration, isGroupChat, customerId, staffId, chatId, corpId])
useEffect(() => { useEffect(() => {