Skip to content

Commit 8a41009

Browse files
wehosHongzhi Wenclaude
authored
fix(proactive): 修复语音模式 mini-game 邀请「现在不想玩」反复出现 (Project-N-E-K-O#1641)
* fix(proactive): 修复语音模式 mini-game 邀请「现在不想玩」反复出现 语音模式下 handle_input_transcript 在函数顶部无条件刷新 last_user_activity_time(含 AI TTS 回声 + 空 VAD 噪声),而语音 proactive tick 的隐式 dismiss 拿它判定「用户是否已回应邀请」。AI 念邀请台词的回声立刻 把活动时间刷到 > delivered_at,下个 tick 误判已回应 → 把 pending 邀请清成 'later'(5min)+ 撤掉按钮,用户随后点「现在不想玩」落到 expired、真正的 5h decline 起不来、5min 后反复重来。文本模式无此问题(无麦克风,advance 用 activity tracker 的真实消息时间)。 - 新增 last_user_message_time(仅真实、非空、非回声用户输入刷新),语音 advance 改用它而非被污染的 last_user_activity_time - 语音转写路径补上 mini-game 邀请关键词兜底(与文本路径对偶抽成共享方法), 口头「现在不想玩」也能触发真 decline(5h)而非退化成 5min - 6 个回归测试(关键词分发/WS 推送/非语音不重复/回声不刷真消息戳/空转录不刷/ 真消息刷) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(proactive): last_user_message_time 复用转写到达时刻,免 takeover await 推迟 codex P2:原本在真消息块用 time.time() 记 last_user_message_time,是在 takeover dispatcher 的 await 之后;改成复用函数顶部捕获的到达时刻 _transcript_arrival_ts。当前架构下 takeover 注册 ⟺ game route active ⟺ proactive 入口早退不投递 invite,race 实际不触发,但时间戳语义更准(=到达 时刻)+ 防御未来互斥关系变化。加回归测试锁住。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(proactive): 文本路径 last_user_message_time 仅 strip 非空才刷,与语音对偶 CodeRabbit Minor:文本路径原本对任意 str(含空白 " ")都刷 last_user_message_time, 与语音路径 `if transcript_text:` 不对偶。加 `if data.strip():` gate。 该字段唯一读取点是 voice proactive 隐式 dismiss(system_router.py:4657),文本 advance 走 activity_snapshot.seconds_since_user_msg、不读它,故此前无条件刷新实际 不影响 invite 行为;加 gate 为语义一致 + 防御未来。last_user_activity_time 保持 无条件刷(服务 idle reset,语义=有没有发请求)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 94cbf47 commit 8a41009

3 files changed

Lines changed: 262 additions & 46 deletions

File tree

main_logic/core.py

Lines changed: 83 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,14 @@ def __init__(self, sync_message_queue, lanlan_name, lanlan_prompt):
902902
# 用户活动时间戳:用于主动搭话检测最近是否有用户输入
903903
self.last_user_activity_time = None # float timestamp or None
904904

905+
# 用户「真实消息」时间戳:仅在非空、非 AI 回声的真用户输入时刷新(语音
906+
# 真转录 / 文本输入),不含 VAD 空噪声、麦克风录回 AI 自己 TTS 的回声。
907+
# 区别于 last_user_activity_time(顶部无条件刷新,含回声/空噪声)——后者拿
908+
# 来判 mini-game 邀请「用户是否已回应」会被 AI 念邀请台词的回声污染,导致
909+
# 隐式 dismiss 在用户还没点按钮前就把 pending 邀请清掉、按钮撤走,用户随后
910+
# 点「现在不想玩」落到 expired、真正的 decline 冷却起不来、邀请反复重来。
911+
self.last_user_message_time = None # float timestamp or None
912+
905913
# 用户静默 ≥ IDLE_SESSION_RESET_THRESHOLD_SECONDS 时主动断 session 的
906914
# 后台 loop。lazily 在首次 start_session 时启动,永久存活(per-manager
907915
# 单例),无 active session 时 sleep 后继续轮询。
@@ -2098,6 +2106,50 @@ def _should_suppress_dirty_voice_transcript(self, transcript_text: str) -> bool:
20982106
recent_ai_text = getattr(self, "_recent_ai_voice_echo_text", "") or ""
20992107
return _looks_like_recent_ai_echo(transcript_text, recent_ai_text)
21002108

2109+
async def _dispatch_mini_game_invite_keyword(self, user_text: str) -> None:
2110+
"""扫一遍用户原话里的 mini-game 邀请 accept/decline/later 关键词,命中即
2111+
触发对应 state 转换 + 推 ``mini_game_invite_resolved`` 让前端 dismiss
2112+
ChoicePrompt(accept 时兼当 launch 信号带 game_url)。
2113+
2114+
文本输入路径(``_process_stream_data_internal``)与语音转写路径
2115+
(``handle_input_transcript``)共用——语音用户没法点 ChoicePrompt 三按钮,
2116+
只能说话;口头"现在不想玩"必须和打字 / 点按钮一样触发真正的 decline 冷却。
2117+
否则语音口头拒绝既不算 decline,又会被下一个 proactive tick 的
2118+
``_mini_game_invite_advance_response`` 当成隐式 dismiss = 'later'(只抑制
2119+
5min),邀请反复重来。**不吃掉消息**:普通 chat 流水线仍然回应这条话。
2120+
2121+
main_routers' keyword matcher is registered as a hook on the bus
2122+
(see app/runtime_bindings.py). Dispatcher swallows per-hook errors;
2123+
if no hook is bound (e.g. entrypoint without main_routers), result
2124+
is None.
2125+
"""
2126+
outcome = dispatch_text_user_message(self.lanlan_name, user_text or '')
2127+
# 推一条 mini_game_invite_resolved 给前端:accept 时兼当 launch 信号
2128+
# (带 game_url),decline/later 时让 ChoicePrompt UI 清掉不让按钮挂着——
2129+
# codex P2 指出,原版只对 accept 推,decline/later 命中后前端 prompt 不
2130+
# 消失,用户后续点按钮会被 endpoint 当 expired,state 早变了。
2131+
if not (outcome and outcome.get('action')):
2132+
return
2133+
try:
2134+
if self.websocket and hasattr(self.websocket, 'send_json'):
2135+
ws_state = getattr(self.websocket, 'client_state', None)
2136+
if ws_state is None or ws_state == ws_state.CONNECTED:
2137+
payload = {
2138+
'type': 'mini_game_invite_resolved',
2139+
'session_id': outcome.get('session_id') or '',
2140+
'action': outcome['action'],
2141+
}
2142+
if outcome.get('game_url'):
2143+
payload['game_url'] = outcome['game_url']
2144+
if outcome.get('game_type'):
2145+
payload['game_type'] = outcome['game_type']
2146+
await self.websocket.send_json(payload)
2147+
except Exception as _push_err:
2148+
logger.warning(
2149+
f"[{self.lanlan_name}] mini_game_invite_resolved "
2150+
f"WS push failed: {_push_err}",
2151+
)
2152+
21012153
async def handle_input_transcript(self, transcript: str, *, is_voice_source: bool = True):
21022154
"""输入转录回调:同步转录文本到消息队列和缓存,并发送到前端显示
21032155
@@ -2114,8 +2166,13 @@ async def handle_input_transcript(self, transcript: str, *, is_voice_source: boo
21142166
transcript_text = transcript.strip()
21152167
voice_rms_recorded = False
21162168

2117-
# 更新用户活动时间戳(用于主动搭话检测)
2118-
self.last_user_activity_time = time.time()
2169+
# 更新用户活动时间戳(用于主动搭话检测)。先捕获「转写到达时刻」局部变量,
2170+
# 下面 last_user_message_time 复用同一时刻——若 takeover dispatcher 注册,
2171+
# 这条转写会先 await 它再走到下面的真消息块;用 await 之后的 time.time() 会
2172+
# 把时间戳推迟,万一 await 期间投递了 invite,invite 之前说的话会被记成 >
2173+
# delivered_at、被下个 tick 误判成 invite 之后的回应(codex P2)。
2174+
_transcript_arrival_ts = time.time()
2175+
self.last_user_activity_time = _transcript_arrival_ts
21192176
if (
21202177
is_voice_source
21212178
and transcript_text
@@ -2169,6 +2226,11 @@ async def handle_input_transcript(self, transcript: str, *, is_voice_source: boo
21692226
# emotion-tier LLM 用——空 transcript 这些副作用都不该触发。
21702227
if transcript_text:
21712228
self._activity_tracker.on_user_message(text=transcript)
2229+
# 真实用户语音消息(已过 echo 抑制 + 非空)才刷「真消息」时间戳,
2230+
# 给 mini-game 邀请隐式 dismiss 用,避免回声/空噪声误判用户已回应。
2231+
# 用顶部捕获的到达时刻而非此处 time.time():takeover dispatcher 的
2232+
# await 不会把它推迟到 await 之后(codex P2)。
2233+
self.last_user_message_time = _transcript_arrival_ts
21722234
self._session_turn_count += 1
21732235
# Telemetry:D1 漏斗——本进程首条用户消息(语音路径)。
21742236
try:
@@ -2187,6 +2249,13 @@ async def handle_input_transcript(self, transcript: str, *, is_voice_source: boo
21872249
# 这里只覆盖语音路径,避免 openclaw handoff(is_voice_source=False)
21882250
# 重复发布。
21892251
self._publish_user_utterance_to_plugin_bus(transcript, is_voice_source=True)
2252+
2253+
# Mini-game 邀请关键词兜底:与文本路径
2254+
# (_process_stream_data_internal)对偶。语音用户没法点
2255+
# ChoicePrompt 三按钮,只能说话——口头"现在不想玩"必须和打字 /
2256+
# 点按钮一样触发真正的 decline 冷却,否则邀请会按 5min 隐式
2257+
# dismiss 反复重来。详见 _dispatch_mini_game_invite_keyword。
2258+
await self._dispatch_mini_game_invite_keyword(transcript)
21902259
else:
21912260
# Non-voice reuse of this method (e.g. openclaw text handoff).
21922261
# Skip activity-tracker hooks entirely — the text-mode entry
@@ -7146,6 +7215,13 @@ async def _process_stream_data_internal(self, message: dict):
71467215
# 对偶)。idle reset loop 依赖该字段判断静默时长,文本路径不补的话
71477216
# 纯文本会话永远满足"静默 ≥ 30 min"被误重置。
71487217
self.last_user_activity_time = time.time()
7218+
# 「真消息」时间戳:strip 后非空才刷,与语音路径
7219+
# `if transcript_text:` 对偶——空白输入不算真实回应,否则会误
7220+
# 推进 mini-game 邀请隐式 dismiss 判定(CodeRabbit)。注意
7221+
# last_user_activity_time 仍无条件刷(服务 idle reset,语义是
7222+
# 「有没有发请求」,与「是不是真消息」不同)。
7223+
if data.strip():
7224+
self.last_user_message_time = time.time()
71497225

71507226
# 更新字数限制(可能用户在对话期间修改了设置)
71517227
if hasattr(self.session, 'update_max_response_length'):
@@ -7199,46 +7275,13 @@ async def _process_stream_data_internal(self, message: dict):
71997275
)
72007276

72017277
# Mini-game 邀请的关键词文本兜底(PR #1141 follow-up E2)。
7202-
# 用户在 pending 邀请期间自己打字(没点 ChoicePrompt 三按
7203-
# 钮)→ 扫关键词命中就触发对应 state 转换。**不吃掉消息**:
7204-
# 继续走普通 chat 流水线,AI 仍然会回应这条话——AI 收到的
7205-
# 上下文里也含这条用户输入,所以模型会自然把"好啊"、"不
7206-
# 玩了"之类的回复处理掉。仅做 state side effect + accept 时
7207-
# 推一条 mini_game_launch WS 让前端 window.open 游戏。
7208-
# main_routers' keyword matcher is registered as a hook
7209-
# on the bus (see app/runtime_bindings.py). Dispatcher
7210-
# swallows per-hook errors; if no hook is bound (e.g.
7211-
# entrypoint without main_routers), result is None.
7212-
_kw_outcome = dispatch_text_user_message(
7213-
self.lanlan_name,
7278+
# 用户在 pending 邀请期间自己打字(没点 ChoicePrompt 三按钮)
7279+
# → 扫关键词命中就触发对应 state 转换。与语音转写路径
7280+
# (handle_input_transcript)共用同一方法,逻辑见
7281+
# _dispatch_mini_game_invite_keyword。
7282+
await self._dispatch_mini_game_invite_keyword(
72147283
data if isinstance(data, str) else '',
72157284
)
7216-
# 推一条 mini_game_invite_resolved 给前端:accept 时兼当 launch
7217-
# 信号(带 game_url),decline/later 时让 ChoicePrompt UI 清掉
7218-
# 不让按钮挂着——codex P2 指出,原版只对 accept 推,
7219-
# decline/later keyword 命中后前端 prompt 不消失,用户后续点
7220-
# 按钮会被 endpoint 当 expired,state 早变了。
7221-
if _kw_outcome and _kw_outcome.get('action'):
7222-
try:
7223-
if (self.websocket
7224-
and hasattr(self.websocket, 'send_json')):
7225-
ws_state = getattr(self.websocket, 'client_state', None)
7226-
if ws_state is None or ws_state == ws_state.CONNECTED:
7227-
payload = {
7228-
'type': 'mini_game_invite_resolved',
7229-
'session_id': _kw_outcome.get('session_id') or '',
7230-
'action': _kw_outcome['action'],
7231-
}
7232-
if _kw_outcome.get('game_url'):
7233-
payload['game_url'] = _kw_outcome['game_url']
7234-
if _kw_outcome.get('game_type'):
7235-
payload['game_type'] = _kw_outcome['game_type']
7236-
await self.websocket.send_json(payload)
7237-
except Exception as _push_err:
7238-
logger.warning(
7239-
f"[{self.lanlan_name}] mini_game_invite_resolved "
7240-
f"WS push failed: {_push_err}",
7241-
)
72427285

72437286
should_handoff, openclaw_messages = await self._should_handoff_text_to_openclaw(data)
72447287
if should_handoff:

main_routers/system_router.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4641,12 +4641,20 @@ async def proactive_chat(request: Request):
46414641
# can_start_proactive 做 409 判定即可。
46424642
if data.get('voice_mode') and mgr.is_active and isinstance(mgr.session, OmniRealtimeClient):
46434643
# Mini-game invite 状态机推进:voice fast path 不走 activity tracker,
4644-
# 直接用 mgr.last_user_activity_time(session 自己跟踪 RMS / 文本输入
4645-
# 活动)作为「用户最后一次活动时间」喂给 advance_response。否则纯
4646-
# voice 用户收到 mini-game 邀请回应后,pending 永远翻不掉,邀请会被
4647-
# 永久抑制;CodeRabbit Major review 指出。
4644+
# 直接用 session 自己跟踪的「用户最后一次真实消息时间」喂给
4645+
# advance_response。否则纯 voice 用户收到 mini-game 邀请回应后,
4646+
# pending 永远翻不掉,邀请会被永久抑制;CodeRabbit Major review 指出。
4647+
#
4648+
# ⚠️ 用 last_user_message_time(仅真实非空非 echo 用户输入)而非
4649+
# last_user_activity_time(顶部无条件刷新,含 VAD 空噪声 + 麦克风录回
4650+
# AI 自己 TTS 的回声)。后者会被 AI 念邀请台词的回声污染:邀请投递后
4651+
# 回声立刻把 activity 刷到 > delivered_at,下一个 tick 的隐式 dismiss
4652+
# 误判「用户已回应」→ 把 pending 邀请清成 'later'(5min)+ 撤掉按钮,
4653+
# 用户随后点「现在不想玩」落到 expired、真正的 5h decline 起不来、邀请
4654+
# 5min 后反复重来。改用真消息时间戳后,纯点按钮(不说话)的用户活动
4655+
# 时间不会越过 delivered_at,pending 一直留到用户显式点按钮 / 说话。
46484656
_voice_advance_outcome = _mini_game_invite_advance_response(
4649-
lanlan_name, getattr(mgr, 'last_user_activity_time', None),
4657+
lanlan_name, getattr(mgr, 'last_user_message_time', None),
46504658
)
46514659
# advance 触发了隐式 dismiss → 推 WS 让前端清掉 prompt UI(cross-window
46524660
# 一致性)。codex P2 指出非按钮路径漏推 WS 让 UI 挂着。

0 commit comments

Comments
 (0)