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,12 +104,16 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
setGenerating(true) setGenerating(true)
setLoading(false) setLoading(false)
setError('')
retryCountRef.current = 0
const attempt = () => {
try { try {
// 单聊才获取画像标签 // 仅首次连接时获取画像标签,重试时不重复调用
if (!isGroupChat && customerId) { if (retryCountRef.current === 0 && !isGroupChat && customerId) {
const profileRes = await fetch(`/api/v1/intent/profile/${encodeURIComponent(customerId)}?corpId=${corpId}`) fetch(`/api/v1/intent/profile/${encodeURIComponent(customerId)}?corpId=${corpId}`)
const profileData = await profileRes.json() .then(res => res.json())
.then(profileData => {
if (profileData.code === 0 && profileData.data) { if (profileData.code === 0 && profileData.data) {
const p = profileData.data const p = profileData.data
const tags: string[] = [] const tags: string[] = []
@ -120,6 +125,8 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
if (p.decisionStage) tags.push(p.decisionStage) if (p.decisionStage) tags.push(p.decisionStage)
setProfileTags(tags) setProfileTags(tags)
} }
})
.catch(e => console.error('获取学员画像失败:', e))
} }
// 构建 SSE URL:群聊传 roomid,单聊传 fromUser+toUser+staffName // 构建 SSE URL:群聊传 roomid,单聊传 fromUser+toUser+staffName
@ -135,7 +142,7 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
} }
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
@ -148,6 +155,10 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
} catch (e) { console.error('SSE start error:', e) } } catch (e) { console.error('SSE start error:', e) }
}) })
es.addEventListener('ping', (_event: MessageEvent) => {
// 服务端心跳保活,无需处理
})
es.addEventListener('item-start', (event: MessageEvent) => { es.addEventListener('item-start', (event: MessageEvent) => {
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
@ -187,19 +198,30 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
if (data.type === 'done') { if (data.type === 'done') {
setGenerating(false) setGenerating(false)
es.close() es.close()
retryCountRef.current = 0
} }
} catch (e) { console.error('SSE done error:', e) } } catch (e) { console.error('SSE done error:', e) }
}) })
es.addEventListener('error', (event: MessageEvent) => { es.addEventListener('error', (event: MessageEvent) => {
es.close()
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
setError(data.message || '生成失败') setError(data.message || '生成失败')
} catch (e) {
setError('连接中断')
}
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) { } catch (e) {
@ -207,6 +229,9 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
setError('网络错误,请重试') setError('网络错误,请重试')
setGenerating(false) setGenerating(false)
} }
}
attempt()
}, [canStartGeneration, isGroupChat, customerId, staffId, chatId, corpId]) }, [canStartGeneration, isGroupChat, customerId, staffId, chatId, corpId])
useEffect(() => { useEffect(() => {