新增: 跨端用户消息同步
This commit is contained in:
@@ -373,6 +373,11 @@ export type AiChatEvent = ({
|
||||
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean
|
||||
} | {
|
||||
type: 'AiError'; error: string; error_type?: AiErrorType
|
||||
} | {
|
||||
// F-260622-02 跨端用户消息同步:远程(miniapp)发来的用户消息,后端 route_send_message
|
||||
// 放行后 publish_event 广播。桌面 useAiEvents 收到后补 user 气泡(桌面本地 sendMessage 是
|
||||
// 乐观渲染不发此事件)。handleEvent 去重防双气泡(末条已是同 content user 跳过)。
|
||||
type: 'AiUserMessage'; message: string
|
||||
} | {
|
||||
type: 'AiAgentRound'; round: number
|
||||
} | {
|
||||
|
||||
@@ -313,16 +313,23 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiMaxRoundsReached':
|
||||
case 'AiMaxRoundsReached': {
|
||||
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。
|
||||
// 后端已 save 落库,这里仅翻 pendingMaxRounds 驱动 UI 卡片;看门狗已由
|
||||
// NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。
|
||||
// BUG-260623-06: NO_RESET 只跳 reset 不 clear,达 max 前活跃事件(delta/tool)设的 timer 仍走,
|
||||
// 130s 后触发 onStreamTimeout → generating=true(后端保持)+ tool completed → 误报
|
||||
// "工具已执行完成,后续回复中断"(实测用户报"对话完成后等一会弹出")。
|
||||
// 修:对齐 AiApprovalRequired/AiDirAuthRequired(等用户场景均 clear),达 max 也 clear watchdog。
|
||||
// 用户点继续(ai_continue_loop→后端续跑→delta reset)/停止(ai_stop_loop→AiCompleted clear)时重计。
|
||||
clearStreamWatchdog(event.conversation_id || undefined)
|
||||
// TD-260621-02 per-conv:存挂起 convId(非 boolean),消费方按 activeConversationId 精确比对。
|
||||
// 注:无 convId 时**不**设 null(=无挂起,达 max 操作卡丢失)——保留原值兜底。
|
||||
// 用 activeConversationId 兜底:达 max 事件理论上必带 convId,无时默认属当前活跃会话,
|
||||
// 优于清空挂起导致 MaxRoundsCard 守卫误判无挂起(用户达 max 操作卡凭空消失)。
|
||||
pendingMaxRounds.value = event.conversation_id || state.activeConversationId || pendingMaxRounds.value
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiDirAuthRequired': {
|
||||
// F-260619-03 Phase B: 路径授权挂起(后端 generating 保持 true,等用户决策)。
|
||||
@@ -528,6 +535,33 @@ function handleConvStateEvent(event: AiChatEvent): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/** 跨端用户消息域:F-260622-02 miniapp→桌面 user 气泡同步。
|
||||
*
|
||||
* 仅处理 AiUserMessage 一个事件。语义:远程(miniapp)发来的用户消息经后端 publish_event
|
||||
* 广播到 ai_event_bus,桌面收到后补 user 气泡(桌面本地 sendMessage 是乐观渲染不发此事件,
|
||||
* 故桌面 store 不会有这条 user 气泡,需要此事件填补,否则桌面只看到孤立 assistant 响应气泡)。
|
||||
*
|
||||
* 去重(防双气泡):若末条消息已是同 content 的 user(理论上桌面本地已乐观 push 过,
|
||||
* 或 miniapp 自身乐观 push 后又被此事件回灌),跳过不重复 push。MVP 按末条 + content 比对
|
||||
* 足够覆盖单会话场景;极端情况下两条不同 user 恰巧同 content 紧邻不会丢(末条不同才 push)。
|
||||
*
|
||||
* 返回值:true=命中 AiUserMessage;false=其他事件。 */
|
||||
function handleUserMessageEvent(event: AiChatEvent): boolean {
|
||||
if (event.type !== 'AiUserMessage') return false
|
||||
// 去重:末条已是 user 且 content 相同则跳过(防桌面本地乐观 push + 事件回灌双气泡)。
|
||||
const last = state.messages[state.messages.length - 1]
|
||||
if (last && last.role === 'user' && last.content === event.message) {
|
||||
return true
|
||||
}
|
||||
state.messages.push({
|
||||
id: `user-${nextMsgId()}`,
|
||||
role: 'user',
|
||||
content: event.message,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError) */
|
||||
function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
switch (event.type) {
|
||||
@@ -710,6 +744,12 @@ export function handleEvent(event: AiChatEvent) {
|
||||
// 在 isCurrent 守卫**之前**处理,避免切走会话时 conv_state 不更新致停止按钮/MaxRoundsCard 错态。
|
||||
// 该事件非流式活跃信号,不重置看门狗、不进 generatingConvs 通用 add 逻辑(由 conv_state 值决定 add/delete)。
|
||||
if (handleConvStateEvent(event)) return
|
||||
// F-260622-02 跨端用户消息同步:在 isCurrent 守卫**之前**处理——AiUserMessage 是补 user 气泡
|
||||
// 信号(非流式活跃事件),不应触发 watchdog reset / generatingConvs add(后续 AiAgentRound/
|
||||
// AiTextDelta 等活跃事件会正常触发)。也不受"非当前会话"过滤——理论上此事件必带 conversation_id
|
||||
// 且属当前会话(后端 route_send_message 在放行时广播),即使因切走会话到达也仅补 user 气泡,
|
||||
// 不影响其他态(handleUserMessageEvent 去重幂等)。
|
||||
if (handleUserMessageEvent(event)) return
|
||||
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏
|
||||
// L1 求助协议(§2.3):AiHelpRequired 同 AiError 后端已 guard.reset 终止 loop,需移出生成集。
|
||||
const isCurrent = !convId || convId === state.activeConversationId
|
||||
|
||||
@@ -47,6 +47,19 @@ let _legacyWatchdog: ReturnType<typeof setTimeout> | null = null
|
||||
* (legacy fallback 路径)时不带 convId,generatingConvs 不动(全局兜底,行为对齐旧版)。
|
||||
*/
|
||||
export function onStreamTimeout(convId?: string) {
|
||||
// 根治守卫(TD-260621-01 遗留):看门狗 reset(doSend 走 legacy _legacyWatchdog)/clear
|
||||
// (AiCompleted 走 per-conv _watchdogTimers Map)路径不对齐,clear 传 convId 时只清 Map 不动 legacy,
|
||||
// 致正常收尾(AiCompleted 已清 streaming)后 legacy timer 残留,130s 到期触发本函数补
|
||||
// "⚠ 工具已执行完成,后续回复中断"误报气泡(实测:用户报"回复完后输出该报错")。
|
||||
// 守卫:触发时若该 conv 已不在生成态(已正常收尾),判定为残留 timer,静默清不补错误消息。
|
||||
// 真卡死(streaming/generating 仍 true)不受影响,仍走原 forceReset + 报错兜底。
|
||||
const stillGenerating = convId
|
||||
? state.generatingConvs.has(convId)
|
||||
: state.streaming
|
||||
if (!stillGenerating) {
|
||||
clearStreamWatchdog(convId)
|
||||
return
|
||||
}
|
||||
// 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。
|
||||
// TD-260621-01:携带 convId 时仅清该 conv 的 generatingConvs(per-conv);不传时全局兜底不动 Set。
|
||||
forceResetStreaming('onStreamTimeout(130s 无数据)', convId)
|
||||
@@ -108,6 +121,9 @@ export function resetStreamWatchdog(convId?: string) {
|
||||
if (old) clearTimeout(old)
|
||||
const timer = setTimeout(() => onStreamTimeout(convId), STREAM_TIMEOUT_MS)
|
||||
_watchdogTimers.set(convId, timer)
|
||||
// 根治(TD-260621-01 遗留):切到 per-conv 时清 legacy(doSend 启动的 legacy timer 与活跃事件
|
||||
// per-conv 共存期,legacy 是残留)。防 AiCompleted clear(convId) 清不到 legacy 致 130s 误报。
|
||||
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
|
||||
} else {
|
||||
// 禁动域 fallback:行为对齐旧版单 timer。
|
||||
if (_legacyWatchdog) clearTimeout(_legacyWatchdog)
|
||||
@@ -126,6 +142,10 @@ export function clearStreamWatchdog(convId?: string) {
|
||||
if (convId) {
|
||||
const timer = _watchdogTimers.get(convId)
|
||||
if (timer) { clearTimeout(timer); _watchdogTimers.delete(convId) }
|
||||
// 根治(TD-260621-01 遗留):doSend reset 走 legacy,AiCompleted clear 走 per-conv Map,clear 清不到
|
||||
// legacy 致 130s 残留误报。批2 单 loop 阶段 legacy 单例只属当前会话,clear(convId) 兜底清 legacy 安全;
|
||||
// 批3+ 多会话落地时 useAiSend reset 迁 per-conv + 删 legacy fallback,此兜底随之移除。
|
||||
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
|
||||
} else {
|
||||
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user