新增: 跨端用户消息同步

This commit is contained in:
2026-06-24 00:27:08 +08:00
parent 698d981962
commit 78b48d871b
3 changed files with 66 additions and 1 deletions

View File

@@ -373,6 +373,11 @@ export type AiChatEvent = ({
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean
} | { } | {
type: 'AiError'; error: string; error_type?: AiErrorType 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 type: 'AiAgentRound'; round: number
} | { } | {

View File

@@ -313,16 +313,23 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
return true return true
} }
case 'AiMaxRoundsReached': case 'AiMaxRoundsReached': {
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。 // F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。
// 后端已 save 落库,这里仅翻 pendingMaxRounds 驱动 UI 卡片;看门狗已由 // 后端已 save 落库,这里仅翻 pendingMaxRounds 驱动 UI 卡片;看门狗已由
// NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。 // 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 精确比对。 // TD-260621-02 per-conv:存挂起 convId(非 boolean),消费方按 activeConversationId 精确比对。
// 注:无 convId 时**不**设 null(=无挂起,达 max 操作卡丢失)——保留原值兜底。 // 注:无 convId 时**不**设 null(=无挂起,达 max 操作卡丢失)——保留原值兜底。
// 用 activeConversationId 兜底:达 max 事件理论上必带 convId,无时默认属当前活跃会话, // 用 activeConversationId 兜底:达 max 事件理论上必带 convId,无时默认属当前活跃会话,
// 优于清空挂起导致 MaxRoundsCard 守卫误判无挂起(用户达 max 操作卡凭空消失)。 // 优于清空挂起导致 MaxRoundsCard 守卫误判无挂起(用户达 max 操作卡凭空消失)。
pendingMaxRounds.value = event.conversation_id || state.activeConversationId || pendingMaxRounds.value pendingMaxRounds.value = event.conversation_id || state.activeConversationId || pendingMaxRounds.value
return true return true
}
case 'AiDirAuthRequired': { case 'AiDirAuthRequired': {
// F-260619-03 Phase B: 路径授权挂起(后端 generating 保持 true,等用户决策)。 // F-260619-03 Phase B: 路径授权挂起(后端 generating 保持 true,等用户决策)。
@@ -528,6 +535,33 @@ function handleConvStateEvent(event: AiChatEvent): boolean {
return true 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) */ /** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError) */
function handleLifecycleEvent(event: AiChatEvent): boolean { function handleLifecycleEvent(event: AiChatEvent): boolean {
switch (event.type) { switch (event.type) {
@@ -710,6 +744,12 @@ export function handleEvent(event: AiChatEvent) {
// 在 isCurrent 守卫**之前**处理,避免切走会话时 conv_state 不更新致停止按钮/MaxRoundsCard 错态。 // 在 isCurrent 守卫**之前**处理,避免切走会话时 conv_state 不更新致停止按钮/MaxRoundsCard 错态。
// 该事件非流式活跃信号,不重置看门狗、不进 generatingConvs 通用 add 逻辑(由 conv_state 值决定 add/delete)。 // 该事件非流式活跃信号,不重置看门狗、不进 generatingConvs 通用 add 逻辑(由 conv_state 值决定 add/delete)。
if (handleConvStateEvent(event)) return 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,需移出生成集。 // L1 求助协议(§2.3):AiHelpRequired 同 AiError 后端已 guard.reset 终止 loop,需移出生成集。
const isCurrent = !convId || convId === state.activeConversationId const isCurrent = !convId || convId === state.activeConversationId

View File

@@ -47,6 +47,19 @@ let _legacyWatchdog: ReturnType<typeof setTimeout> | null = null
* (legacy fallback 路径)时不带 convId,generatingConvs 不动(全局兜底,行为对齐旧版)。 * (legacy fallback 路径)时不带 convId,generatingConvs 不动(全局兜底,行为对齐旧版)。
*/ */
export function onStreamTimeout(convId?: string) { 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 日志)。 // 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。
// TD-260621-01:携带 convId 时仅清该 conv 的 generatingConvs(per-conv);不传时全局兜底不动 Set。 // TD-260621-01:携带 convId 时仅清该 conv 的 generatingConvs(per-conv);不传时全局兜底不动 Set。
forceResetStreaming('onStreamTimeout(130s 无数据)', convId) forceResetStreaming('onStreamTimeout(130s 无数据)', convId)
@@ -108,6 +121,9 @@ export function resetStreamWatchdog(convId?: string) {
if (old) clearTimeout(old) if (old) clearTimeout(old)
const timer = setTimeout(() => onStreamTimeout(convId), STREAM_TIMEOUT_MS) const timer = setTimeout(() => onStreamTimeout(convId), STREAM_TIMEOUT_MS)
_watchdogTimers.set(convId, timer) _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 { } else {
// 禁动域 fallback:行为对齐旧版单 timer。 // 禁动域 fallback:行为对齐旧版单 timer。
if (_legacyWatchdog) clearTimeout(_legacyWatchdog) if (_legacyWatchdog) clearTimeout(_legacyWatchdog)
@@ -126,6 +142,10 @@ export function clearStreamWatchdog(convId?: string) {
if (convId) { if (convId) {
const timer = _watchdogTimers.get(convId) const timer = _watchdogTimers.get(convId)
if (timer) { clearTimeout(timer); _watchdogTimers.delete(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 { } else {
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null } if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
} }