Skip to content

Commit 814192c

Browse files
wehosHongzhi Wenclaude
authored
feat(proactive): mini-game 邀请三选项按钮 + 关键词文本兜底(PR Project-N-E-K-O#1141 follow-up #2) (Project-N-E-K-O#1145)
* feat(proactive): mini-game 邀请三选项按钮 + 关键词文本兜底(PR Project-N-E-K-O#1141 follow-up #2) [PR Project-N-E-K-O#1143](Project-N-E-K-O#1143) 之后做掉 spec 第 6 条 + D2 短期抑制 + E2 关键词文本匹配,前端复用 galgame 按钮 UI 框架但 不走 galgame 开关。 ## 行为合同 邀请投递时后端推 WS message ``mini_game_invite_options`` 给前端,带 3 个本 地化按钮(accept / decline / later)+ session_id + game_type。前端用通用 ChoicePrompt 抽象渲染。用户点击: - **来一局!** → mark responded(启动 1h+10 chats 冷却)+ 后端返回 ``game_url=/soccer_demo?lanlan_name=...&session_id=...`` → 前端 ``window.open`` 让 Electron 主进程 ``setWindowOpenHandler`` 拦截开独立 BrowserWindow(普通浏览器是新 tab) - **现在不想玩** → mark responded(同样冷却但不开游戏) - **等一会儿** (D2) → reset state(``delivered_at=None`` 等清零,让 force-first 与普通 10% 都恢复正常)+ 加 ``suppressed_until = now + 5min`` 让下一次 proactive 不会立刻又掷骰 用户没点按钮自己打字("好啊" / "不要" / "晚点说"):core.py 文本入口扫一遍 关键词,命中即触发对应 state 转换;**不吃掉消息**——继续走普通 chat 流水 线,AI 仍然回应这条话。accept 命中后端额外 push ``mini_game_launch`` WS 让前端 window.open 游戏。 ## 关键设计选择 - **通用 ChoicePrompt 抽象** (B 拍板):前端 ``state.choicePrompt`` + ``ChoicePrompt`` schema 类型,``source: 'galgame'|'mini_game_invite'`` 两路并 存。当前 galgame mode 仍走旧 ``galgameOptions`` 字段(BC,零回归);mini-game invite 走新 ``choicePrompt``。未来"对话框 + avatar 旁同步选项"扩展只需加 ``placement`` 字段,组件按 placement 多处渲染。 - **没改 lanlan_frd 仓库**:复用现有 ``setWindowOpenHandler`` 拦截 + ``WS_PROXY_CHANNELS.RAW_MESSAGE`` 从 Pet 主窗 IPC 转给 chat.html,前端 对所有窗口都用 ``window.open`` 一致触发,主进程一处拦截。 - **session_id 防 stale**:每次投递生成 uuid,state.pending_session_id 持有; endpoint 收到不匹配 → ``action='expired'`` 让前端隐藏过期 prompt 而不报错。 - **dedupe 双开**:endpoint 路径 + WS push 路径都可能开同一 session 的游戏, 前端 ``_launchedMiniGameSessionIds`` set 让同一 session 只 ``window.open`` 一次。 ## 改动文件 | 文件 | 改动 | |---|---| | ``config/__init__.py`` | +2 常量 ``LATER_SUPPRESS_SECONDS=300`` + ``MINI_GAME_LAUNCH_URL_BY_GAME`` | | ``config/prompts_proactive.py`` | +``MINI_GAME_INVITE_OPTION_LABELS`` 5 locale × 3 choice + ``MINI_GAME_INVITE_KEYWORDS`` 5 locale × 3 choice | | ``main_routers/system_router.py`` | state 新加 ``pending_session_id`` / ``suppressed_until`` / 投递推 WS payload + 新 endpoint ``/api/mini_game/invite/respond`` + ``_apply_mini_game_invite_choice`` / ``_match_mini_game_invite_keyword`` / ``_maybe_apply_mini_game_invite_keyword`` 三个 helper | | ``main_logic/core.py`` | 文本入口(``_publish_user_utterance_to_plugin_bus`` 之后)调关键词 hook,accept 时推 ``mini_game_launch`` WS | | ``frontend/react-neko-chat/src/message-schema.ts`` | +``ChoiceOption`` / ``ChoicePrompt`` zod schema + types | | ``frontend/react-neko-chat/src/App.tsx`` | +``choicePrompt`` / ``onChoiceSelect`` props,渲染 ``composer-choice-slot``(复用 ``.composer-galgame-*`` CSS) | | ``static/app-react-chat-window.js`` | +``state.choicePrompt`` / ``state._launchedMiniGameSessionIds`` + ``handleChoiceSelect`` / ``handleMiniGameInviteChoice`` / ``setMiniGameInvitePrompt`` / ``launchMiniGameInternal`` + 暴露给 host 对象 | | ``static/app-websocket.js`` | +2 WS message branches: ``mini_game_invite_options`` / ``mini_game_launch`` | | ``templates/soccer_demo.html`` | URL query parse ``lanlan_name`` / ``session_id`` 覆盖默认 ``soccer_demo`` | | ``tests/unit/test_mini_game_invite.py`` | +24 个测试:D2 suppression / 三 choice state machine / 关键词匹配(含 accept-priority) / WS payload push / i18n 完整性 | ## Test plan - [x] ``uv run pytest tests/unit/test_mini_game_invite.py`` —— 65 passed - [x] 联跑 proactive + game-router suite (340 tests):全绿,无回归 - [x] React typecheck + build:成功,static/react/neko-chat 产物刷新 - [x] React vitest:36 tests passed(含 23 App tests + galgame BC) - [x] ``check_prompt_hygiene.py`` / ``check_i18n_sync.py`` / ``check_api_trailing_slash.py`` / ``check_frontend_api_trailing_slash.py`` —— 我的改动 clean(其它 worktree 的 hits 与本 PR 无关) - [ ] **手测三 context**(per ``feedback_chat_three_contexts.md`` 必须):index.html 宽屏 / 移动端窄屏 / chat.html Electron multi-window 三条路径分别验证 ChoicePrompt 渲染 + accept 打开 soccer_demo + 退出回主聊天 ## Out of scope - "avatar 旁同步显示选项" placement 扩展(schema 已留口子,后续 PR 加) - Galgame mode options migrate 到 ``choicePrompt`` 抽象(当前 BC 优先,未来统一) - Electron 主进程对 ``/soccer_demo`` URL 的特殊窗口策略(如游戏专用窗口尺寸)—— 当前走通用 setWindowOpenHandler Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): mini-game 邀请 review 反馈(关键词反向 / CSRF / 严校验 / WS payload) 5 条 codex P2 + CodeRabbit Major review 一并处理。 (1) **关键词 priority 反向**(CodeRabbit Major):原 priority accept > later > decline 配单字 '好' '行' 进 accept 列表,`不好` / `我不行` / `不好玩` 这种 含 negation 的句子会被 substring 优先命中 accept 反向触发开游戏。修: - accept 列表改成短语「好啊 / 好的 / 行啊 / 来吧」等,避免和 decline 子串 重叠 - priority 翻成 **decline > later > accept**:含 negation 必判 decline; 含 later 信号优先于 accept("好的等下" 判 later 而不是 accept) 双保险:任一保护层失效另一个仍 catch。 (2) **CSRF / origin 校验**(CodeRabbit Major):`/api/mini_game/invite/respond` 是本地 mutation endpoint 但没走 `_validate_local_mutation_request`。第三方 页面可对 localhost:port 盲 POST 替用户 accept / decline / later 当前邀请。 修:补上和同文件其它 browser-facing mutation 一致的校验。 (3) **session_id 严校验**(CodeRabbit Major):原 `if session_id and pending_sid and session_id != pending_sid: expired` 在 session_id missing 时放过去用当前 pending invite,绕过 stale-session 保护。修:`session_id` 必须存在 + 必须等 于 `pending_session_id`,任一失败都返 expired。 (4) **mini_game_launch WS payload 加 session_id**(codex P2 + CodeRabbit Major 同一处):keyword fallback 路径推 `mini_game_launch` 时漏带 session_id, 前端 `_launchedMiniGameSessionIds` dedupe key 失效,跨路径(按钮 endpoint + keyword WS)同时触发同 invite 时双开窗口。修:`_apply_mini_game_invite_ choice` accept 返回顶层加 `session_id` 字段;core.py 把它放进 WS payload。 (5) **`onChoiceSelect` source 类型**(CodeRabbit Minor):原 `z.string()` 让 zod runtime 校验过松,与 `ChoicePromptSource` TS 类型不一致。修:改 `z.enum`。 测试更新: - `test_keyword_matcher_accept_priority_over_later` → 重写为 `test_keyword_matcher_priority_decline_over_later_over_accept` pin 新优先级 - `test_keyword_matcher_accept_zh` 把 '来!' 改成 '来吧'(accept 列表已不含 '来') - 新增 `test_keyword_matcher_accept_does_not_match_negation`:'不好' / '我不行' / '不好玩' 必须命中 decline,证明双保险生效 - accept open_game test 多断言一行 `result['session_id'] == ...` 66 tests pass,React typecheck + build + vitest 36 全绿。 Refs: - Project-N-E-K-O#1145 (comment) (codex P2) - Project-N-E-K-O#1145 (comment) (CR keyword) - Project-N-E-K-O#1145 (comment) (CR enum) - Project-N-E-K-O#1145 (comment) (CR session_id WS) - Project-N-E-K-O#1145 (comment) (CR CSRF) - Project-N-E-K-O#1145 (comment) (CR strict session_id) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): mini-game invite 前端 CSRF + advance_response dismiss 语义 codex P1 + CodeRabbit Major review,两处真问题: (1) **前端缺 CSRF token**(codex P1):99209030a 给 endpoint 加了 _validate_local_mutation_request 但前端 fetch 没带 X-CSRF-Token header, 所有合法点击都会被 403 拒绝、prompt 已清掉但 invite state 没更新。 修:handleMiniGameInviteChoice 用 window.nekoLocalMutationSecurity 拿 headers,含 token + 403 csrf_validation_failed 自动 refresh + 重试一次的 协议(与同文件其它 prompt mutation 一致)。chat.html 之前没加载 app-prompt-shared.js,补上 script 引入(与 index.html 同序)。 (2) **advance_response 改 dismiss 语义**(CodeRabbit Major):原本「pending invite 期间用户发任意消息」就 mark responded_at + 启动 1h 长冷却。这是 PR Project-N-E-K-O#1141 时代「没按钮 → 用户说话 = 隐式回应」的合约。现在 PR Project-N-E-K-O#1145 引入 了显式三选项按钮,长冷却只该由显式 accept/decline 触发——否则「用户先发 一句别的话再点按钮」会被 endpoint 误判 expired,状态早进 1h 长冷却(违 D2 5min 短抑制语义)。 修:_mini_game_invite_advance_response 改成等同 'later' 选项的 reset+短抑 制语义——pending invite 期间任意非命中消息 → reset state(delivered_at / pending_session_id / responded_at 清零)+ suppressed_until = now + 5min。 保留 ever_delivered(force-first 不再 fire),但不进 1h+10 chats 长冷却。 实现复用 _apply_mini_game_invite_choice('later'),单一事实源。 测试: - test_advance_response_flips_when_user_spoke_after_invite → 重写为 test_advance_response_dismisses_pending_invite_with_short_suppression:断言 state reset + suppressed_until set,responded_at 仍 None。 - test_advance_response_anchors_even_when_proactive_runs_long_after_reply → 替换为 test_advance_response_does_not_trigger_long_cooldown:模拟 codex 指出的 race 场景(先发普通消息 → 用户点按钮),断言 1h 长冷却不被触发, 5min 短抑制后下次 proactive 重新走骰子。 66 tests pass,React typecheck + build + vitest 36 全绿。 Refs: - Project-N-E-K-O#1145 (comment) (codex P1 CSRF) - Project-N-E-K-O#1145 (comment) (CR advance dismiss) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): mini-game invite review 第 4 轮(token 边界 + cross-window dismiss + UX 韧性) codex P1+P2 + CodeRabbit Major+Minor 一并处理。 (1) **关键词 word-boundary 匹配**(codex P1):原 `kw in norm` raw substring 让 短英文 'yes' 命中 'yesterday'、'no' 命中 'know' / 'no idea' 等。引入 `_keyword_matches` helper:ASCII / Cyrillic letter-only keyword 用 `\b...\b` regex(带 cache);CJK / Hiragana / Katakana / Hangul 仍 substring(Python `\w` 含中日韩字符,硬套 word-boundary 反而把"我好啊"漏掉)。 (2) **cross-window dismiss WS broadcast**(codex P2 + CodeRabbit Major): 原版只对 keyword accept 推 mini_game_launch;decline / later keyword 命 中后前端 prompt 不消失;用户后续点按钮被 endpoint 当 expired 处理(state 早变了)。修: - 把 mini_game_launch 合并到 mini_game_invite_resolved 单一事件,accept 时同时携带 game_url 兼当 launch 信号 - endpoint button 路径 + keyword 文本路径 + advance 隐式 dismiss 路径 统一通过 _push_mini_game_invite_resolved push WS - _apply_mini_game_invite_choice 所有分支返回 session_id 让 caller 能 push event - advance_response 改 return outcome dict(之前 None)让 proactive_chat handler 推 WS 实现 cross-window 一致 dismiss (3) **fetch 失败回滚 prompt**(CodeRabbit Major):handleMiniGameInviteChoice 原版立即清 prompt 后 catch 仅 console.warn,网络异常用户点击被静默吞。 修:暂存 rollbackPrompt,catch 内当 prompt 仍空且 session 未 launched 时恢复,让用户能再点。 (4) **options 过滤后再次 length 检查**(CodeRabbit Minor):原版只检 raw options.length,filter 掉空 choice/label 后可能空,渲染空按钮 prompt。 修:cleanedOptions 过滤后 recheck length 才赋值。 (5) **front-end host method rename**:launchMiniGame → handleMiniGameInviteResolved 更准确反映 unified WS event 语义(dismiss + 可选 launch)。 测试: - test_keyword_matcher_no_false_positive_on_substring_words:'yesterday' / 'know' / 'pressure' 不命中 - test_button_endpoint_pushes_resolved_ws_for_all_actions:cooldown / suppress 也 push WS(accept 已有覆盖) - test_push_resolved_includes_game_url_for_open_game:accept 顶层带 game_url - test_push_resolved_noop_without_session_id:空 session 不广播 - test_advance_response_returns_outcome_for_caller_ws_push:advance 改 return result,让 caller push WS 71 tests pass,React typecheck + build + vitest 36 全绿。 Refs: - Project-N-E-K-O#1145 (comment) (codex P1 token boundary) - Project-N-E-K-O#1145 (comment) (codex P2 cross-window dismiss) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): mini-game invite review 第 5 轮(裸 'no' / launched flag / lanlan_name fallback / 测试收紧) 4 条 codex + CodeRabbit Major + Minor。 (1) **裸 'no' 误命中常规英文**(CodeRabbit Major):``\b...\b`` word-boundary 挡住 'yesterday' 这类 substring,但挡不住 "I have no idea" / "no worries" 这类整词短语——'no' 在那里就是单独 word,会命中 decline。修:从 en decline 列表删 'no',改用 'no thanks' / 'nope' / 'don't want' / 'not now' 等 phrase。'after' 同样太宽("after lunch"),改成 'after this'。 (2) **launched flag 必须在 window.open 成功后才设**(codex P2 + CodeRabbit Major):原版 set-before-open 让 popup blocker / throw 永久锁死 session dedupe,prompt 已清掉用户彻底失去入口。改:try-catch 内根据 ``opened`` 标志决定是否设 ``_launchedMiniGameSessionIds``。 (3) **lanlan_name 优先 appState**(CodeRabbit Major):原版只读 ``window.lanlan_config.lanlan_name``——角色切换时 ``appState`` 先更新, ``lanlan_config`` 滞后;用旧 lanlan_name 调 endpoint 会按错误角色查 pending invite 直接返 expired。改成与同文件 GalGame 请求路径一致的 优先级:``appState.lanlan_name`` > ``lanlan_config.lanlan_name``。 (4) **测试断言收紧**(CodeRabbit Minor):原 ``test_keyword_matcher_no_ false_positive_on_long_text`` 用 ``is None or == 'decline'`` 太宽,把 误命中回归也放过去。重写为 ``test_keyword_matcher_decline_via_phrase`` 精确断言 ``== 'decline'``,pin 住 '没空' phrase 契约。 新增 ``test_keyword_matcher_no_false_positive_on_common_english_phrases``: "i have no idea" / "no worries" / "i know what you mean" 必须 None; "no thanks" 仍命中 decline——验证 'no' 删除后边界正确。 72 tests pass,React typecheck + build + vitest 36 全绿。 Refs: - Project-N-E-K-O#1145 (comment) (CR no keyword) - Project-N-E-K-O#1145 (comment) (CR lanlan_name) - Project-N-E-K-O#1145 (comment) (CR launched flag) + codex P2 - Project-N-E-K-O#1145 (comment) (CR test tighten) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): mini-game invite zh accept 删单字'冲',改'一起玩' codex P2:'冲' 是单字 CJK keyword,substring 命中 "我去冲个澡" / "冲咖啡" 等普通对话——CJK 走 substring 没有 word-boundary 兜底,跟 PR 早些时候已 删的 '好' / '行' 同类问题。 修:从 zh accept 删 '冲';同时把 '一起' 改成更精确的 '一起玩'('一起去吃饭' 这种也是接受语义但跟邀请不直接相关,'一起玩' 边界更清晰)。 新测 ``test_keyword_matcher_no_false_positive_on_chong_substring`` 覆盖 "我去冲个澡" / "在冲咖啡" 必须 None;"来吧" 仍命中 accept。73 tests pass。 Refs: - Project-N-E-K-O#1145 (comment) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): mini-game invite 删 zh '可以' / ko '응' 两个 CJK substring 误命中 codex P2 两条相关: (1) **zh '可以'** 是双字 phrase 但被 decline negation "不可以" substring 包含。 decline list 当时没 '不可以',priority decline > accept 救不了——pending invite 期间用户说"不可以"会被错判 accept 开游戏。 修:从 zh accept 删 '可以';同时把 '不可以' 加进 decline 双保险(未来 若误把 '可以' 加回,priority 仍能 catch)。文档化设计原则:accept 短语 必须保证「decline phrase 不含它」。 (2) **ko '응'** 单字 hangul,"적응" / "반응" / "응답" 等常规词含子串命中。 修:从 ko accept 删 '응';保留 '좋아' / '그래' / '가자' / 'ㅇㅇ'。 新测试: - test_keyword_matcher_no_false_positive_on_keyi_negation: "不可以" / "不可以的" 必须 decline,"可以" 单独不再命中 - test_keyword_matcher_no_false_positive_on_korean_eung: "적응 중이야" / "반응이 좋네" 必须 None,"좋아" 仍 accept 75 tests pass。 Refs: - Project-N-E-K-O#1145 (comment) (codex P2 可以) - Project-N-E-K-O#1145 (comment) (codex P2 응) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): clearMessages 也清 mini-game ChoicePrompt 防跨会话残留 codex P2:`setMiniGameInvitePrompt` 全局 state 没在角色切换 / cloud reload 等 路径被 invalidate。`clearMessages()` 现状只清 messages 不清 choicePrompt—— 旧角色的 mini-game prompt 残留在新 context 里,用户点了发旧 session_id 给新 lanlan_name,endpoint 直接 expired 让用户困惑。 修:clearMessages 同时 reset state.choicePrompt + state._launchedMiniGameSessionIds。 后者是 dedupe set,session_id 是 uuid 实际撞概率几乎 0,但对偶清理更干净。 Refs: - Project-N-E-K-O#1145 (comment) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): 防多窗口 mini-game 重复 launch(button=独占触发,keyword=leader 处理) codex P2:`mini_game_invite_resolved` 通过 RAW_MESSAGE IPC 转给所有 page (pet + chat.html),每个 page 都执行 `handleMiniGameInviteResolved`。原版 所有 page 都尝试 `window.open` → 多窗口 Electron 会开多个 game 窗口 (per-page `_launchedMiniGameSessionIds` dedupe set 跨 page 不共享)。 修两侧: (1) **endpoint button path 不推 game_url**:button 路径 chat.html 已通过 HTTP 响应自己 `window.open`,backend 再推 game_url 给所有 page 会导致 pet 也 launch 一次 → 双开。改成 endpoint push WS resolved event 故意 不传 game_url / game_type,仅承担 cross-window dismiss prompt 职责。 (2) **frontend follower 跳过 WS launch**:keyword path 仍推 game_url 让 leader 触发 launch,但多窗口下所有 page 都收到。约定 only **non-follower** page (pet / 单窗口,`window.__NEKO_MULTI_WINDOW__` falsy) 处理 WS-trigger launch;chat.html follower 仅 dismiss UI。Button path 不走这条 WS launch (HTTP 响应里 chat.html 自己 launch),所以不会双开。 Refs: - Project-N-E-K-O#1145 (comment) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): mini-game invite 删 en accept 'okay',加 'not okay/sure/yet' 进 decline 双保险 codex P2:'okay' 即使 word-boundary 也会被 "not okay" 整词命中,priority decline > accept 在 decline list 没 'not okay' 时救不了——pending invite 期间用户说 "not okay" 会被错判 accept 开游戏。同类 'sure' 也有 "not sure" 风险,'yeah' / 'yep' 同。 修: - 删 en accept 'okay'(用户表达接受改用 yes / sure / let's / yeah / yep) - 加 'not okay' / 'not sure' / 'not yet' 进 en decline,priority 双保险—— 即便未来误把 'okay' 加回 accept,'not okay' 仍命中 decline 优先 新测试: - test_keyword_matcher_no_false_positive_on_negated_accept_phrases 覆盖 'not okay' → decline / 'okay' → None / "i'm not sure" → decline / 'sure thing' 仍 accept / "not yet" → decline - 改 test_keyword_matcher_accept_en 把 'OKAY' assertion 换成 'Yeah!' 76 tests pass。 Refs: - Project-N-E-K-O#1145 (comment) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(soccer-demo): /soccer_demo 500 — Jinja 解析 JS 模板字面量里的 {{${k}}} 报 SyntaxError PR Project-N-E-K-O#1110 给 soccer_demo.html 加的 _i18n helper 用 \`{{${k}}}\` 模板字面量 做 i18n 占位符 replaceAll,但整份模板顶部有 Jinja 渲染(VRM_DEFAULT_LIGHTING 注入 vrm_defaults | tojson),FastAPI 的 TemplateResponse 把整文件交给 Jinja parser,遇到 {{${k}}} 会把 {{ 当 Jinja 表达式开头去解析 ${k},$ 不是合法 Python 字符 → TemplateSyntaxError → 路由返 500。 PR Project-N-E-K-O#1110 description 明确写 "Not covered: Full browser playthrough of SoccerDemo",所以这条 latent bug 一直没人触达。PR Project-N-E-K-O#1145 加了显式入口 (mini-game 邀请按钮),手测时第一次撞上。 修:把 replaceAll 的源串从 \`{{${k}}}\` 拆字符拼接成 '{' + '{' + k + '}' + '}',运行期 JS 仍构造出 {{KEY}} 字面量与 i18n message 里的占位符约定一致;Jinja 源码扫描看不到相邻的 {{ 不再误解析。 verify: jinja2 Environment.get_template + render 成功(160079 chars)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): mini-game accept 用 pre-opened popup 保留用户手势防 popup blocker codex P2:原版 fetch endpoint 拿到 game_url 后才 window.open,async fetch resolve 跨过用户点击的同步上下文,浏览器 popup blocker 把 .open 识别为 非用户手势触发拦截 → window.open 返 null → state 已被后端 mark responded 进入 1h 长冷却 → 用户失去重试入口(prompt 也已清掉)。 修:accept 路径在 click handler 同步上下文里**预开**一个空白 popup (``window.open('', '_blank')``),保留用户手势许可;fetch resolve 拿到 game_url 后用 ``preOpenedWindow.location.href = url`` 注入 URL(不再被 popup blocker 拦)。占位页写一行 "Loading mini-game…" 避免 about:blank 一闪。 非 accept outcome (cooldown / suppress / expired) 关掉 pre-opened popup 免得用户看到孤儿空白窗。fetch error 路径同样关掉。 decline / later 路径不预开 popup(option.choice !== 'accept'),不影响。 pre-open 失败 fallback 仍调 launchMiniGameInternal——主路径走 pre-open, fallback 仅是 second chance。 Refs: - Project-N-E-K-O#1145 (comment) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: 修正 mini-game keyword priority 注释(accept-priority → decline > later > accept) CodeRabbit Minor:MINI_GAME_INVITE_KEYWORDS 顶部注释还写着旧的 accept > later > decline,跟当前 _match_mini_game_invite_keyword 实际优先级 decline > later > accept 反向,未来维护看到注释回归到旧逻辑。 修:注释更新为现行 decline-priority 语义;顺便补充 ASCII/Cyrillic word-boundary + CJK substring 的匹配规则差异(codex P1 引入),让单一注释承载完整契约。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): mini-game invite en accept 收紧("let's not play" / "I don't wanna play" 不再误判) CodeRabbit Major:en accept 'let\'s' 太宽——"let's not play" 含 substring 'let\'s' 命中 accept;'wanna play' 同被 "I don't wanna play" 包含。当时 decline list 没列对应 negation phrase,priority decline > accept 救不了。 修: - accept 'let\'s' → "let's play" 更具体(更精确表达接受邀请玩) - 保留 'wanna play' 在 accept;同时 decline 加 "let's not" / "don't wanna" 双保险——priority 兜底防 "I don't wanna play" 等 negation 被错判 accept 新测试: - test_keyword_matcher_decline_negated_let_and_wanna: "let's not play" → decline / "I don't wanna play" → decline / "yeah let's play" 仍 accept 调整 test_keyword_matcher_accept_en:'Let\'s go!' 不再命中('let\'s' 已收紧), 改用 "let's play this" / 'i wanna play' 验证 accept phrase 仍工作。 77 tests pass。 Refs: - Project-N-E-K-O#1145 (comment) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent eb80d84 commit 814192c

11 files changed

Lines changed: 1557 additions & 75 deletions

File tree

config/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,6 +1344,21 @@ def translate_value(val):
13441344
- 与 MINI_GAME_INVITE_COOLDOWN_SECONDS 同时满足才解禁;任一不满足都继续抑制。
13451345
- 上游:_mini_game_invite_in_cooldown 计数侧判定。"""
13461346

1347+
MINI_GAME_INVITE_LATER_SUPPRESS_SECONDS = 5 * 60
1348+
"""用户选择「回头再说」后的短期再掷骰抑制秒数(默认 5min)。
1349+
- D2 语义:reset state(delivered_at/responded_at/chats_since_response 都清零,
1350+
让 force-first 与普通 10% 掷骰都恢复正常)但加一个 ``suppressed_until`` 软门,
1351+
这段时间内 ``_mini_game_invite_in_cooldown`` 仍返回 True 防止下一次 proactive
1352+
立刻又邀请,体感上像"等等再问我"。过了这个窗口下次 proactive 才重新走骰子。
1353+
- 上游:endpoint /api/mini_game/invite/respond 的 'later' action。"""
1354+
1355+
MINI_GAME_LAUNCH_URL_BY_GAME: dict[str, str] = {
1356+
'soccer': '/soccer_demo',
1357+
}
1358+
"""game_type → 实际打开的页面 URL。前端 `window.open(url)` 让 Electron 主进程
1359+
``setWindowOpenHandler`` 拦截开独立 BrowserWindow(普通浏览器是新 tab);URL
1360+
会带上 ``?lanlan_name=...&session_id=...`` query。新 mini-game 加新 entry 即可。"""
1361+
13471362
PROACTIVE_SOURCE_HARD_SKIP_SECONDS = 5 * 3600
13481363
"""主动搭话 source 衰减历史的硬窗口(p_skip=1.0)。
13491364
- 用途:5h 内同一 URL 必跳,超过后按 kind 半衰期指数衰减。
@@ -1641,6 +1656,8 @@ def translate_value(val):
16411656
'MINI_GAME_INVITE_COOLDOWN_CHATS',
16421657
'MINI_GAME_INVITE_NEW_USER_FORCE_AT',
16431658
'MINI_GAME_INVITE_AVAILABLE_GAMES',
1659+
'MINI_GAME_INVITE_LATER_SUPPRESS_SECONDS',
1660+
'MINI_GAME_LAUNCH_URL_BY_GAME',
16441661
'PROACTIVE_SOURCE_HARD_SKIP_SECONDS',
16451662
'PROACTIVE_SOURCE_HALF_LIFE_BY_KIND',
16461663
'PROACTIVE_SOURCE_HALF_LIFE_DEFAULT',

config/prompts_proactive.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2355,6 +2355,116 @@ def get_proactive_format_sections(has_screen: bool, has_web: bool, has_music: bo
23552355
},
23562356
}
23572357

2358+
# ---------- Mini-game 邀请三选项按钮 ----------
2359+
# choice 是 wire-format 标识符(accept/decline/later),不进 UI;UI label 由
2360+
# MINI_GAME_INVITE_OPTION_LABELS 按 locale 渲染。前端 ChoicePrompt 组件读
2361+
# label 直接展示,点击发 ``choice`` 给 endpoint。文案设计:accept 热情但不
2362+
# 过度、decline 客气不冷漠、later 自然不催促,三者语义清晰互不重叠。
2363+
MINI_GAME_INVITE_OPTION_LABELS: dict[str, dict[str, str]] = {
2364+
'zh': {
2365+
'accept': '来一局!',
2366+
'decline': '现在不想玩',
2367+
'later': '等一会儿',
2368+
},
2369+
'en': {
2370+
'accept': "Let's play!",
2371+
'decline': 'Not feeling it',
2372+
'later': 'Maybe later',
2373+
},
2374+
'ja': {
2375+
'accept': 'やろう!',
2376+
'decline': '今はパス',
2377+
'later': 'あとでね',
2378+
},
2379+
'ko': {
2380+
'accept': '좋아, 가자!',
2381+
'decline': '지금은 됐어',
2382+
'later': '좀 이따',
2383+
},
2384+
'ru': {
2385+
'accept': 'Давай сыграем!',
2386+
'decline': 'Сейчас нет настроения',
2387+
'later': 'Чуть позже',
2388+
},
2389+
}
2390+
2391+
# ---------- Mini-game 邀请回应关键词(文本兜底匹配)----------
2392+
# 用户没点按钮、自己打字时("好啊"/"不要"/"晚点说"),后端 message handler 入口
2393+
# 扫一遍这份关键词表:命中即触发对应 action(accept / decline / later),不吃掉
2394+
# 用户消息(继续走普通 chat 流水线)。
2395+
#
2396+
# 匹配规则:消息**全文小写后包含任一关键词**视为命中;ASCII / Cyrillic 走
2397+
# word-boundary regex 防 'yes' 命中 'yesterday';CJK / Hiragana / Katakana /
2398+
# Hangul 走 substring(无 word boundary)。多类同时命中按优先级
2399+
# **decline > later > accept**(含明确 negation 必判 decline,"好的等下" 含
2400+
# accept + later 关键词时判 later——别立刻开游戏)。匹配在
2401+
# main_routers.system_router 的 helper 内做 —— 关键词列表本身放这里集中维护。
2402+
# 早期版本曾用 accept-priority 简单兜底,被 codex / CodeRabbit Major 指出后
2403+
# 改成 decline-priority 防 negation 句误判。
2404+
#
2405+
# 5 native locale 都列:用户可能切语言但仍用中文打字,所以匹配时逐个 locale 全
2406+
# 扫一遍而不是只看 active locale。
2407+
MINI_GAME_INVITE_KEYWORDS: dict[str, dict[str, list[str]]] = {
2408+
'zh': {
2409+
# accept 必须用**短语 / 双字以上**且**不被任何 decline 短语作 substring
2410+
# 包含**——CJK 走 substring 没 word boundary 兜底,priority 仅在 decline
2411+
# 也命中时救场,"不可以" 这种 decline list 没列的 negation phrase 完全
2412+
# 救不了。设计原则:accept 短语必须保证「decline phrase 不含它」。
2413+
# - 单字 '好' '行' 被 "不好" / "我不行" / "不好玩" 包含。
2414+
# - 单字 '玩' '走' 太宽——"不想玩" / "走开"。
2415+
# - 单字 '冲' 也宽——"冲个澡" / "冲咖啡"(codex P2 指出)。
2416+
# - 双字 '可以' 被 "不可以" 包含——decline list 又没 '不可以',
2417+
# priority 救不了(codex P2 指出后删)。
2418+
# 改用「好啊 / 好的 / 行啊 / 来吧 / 一起玩」等明确接受 phrase。
2419+
'accept': ['好啊', '好的', '行啊', '来吧', '一起玩'],
2420+
'decline': ['不要', '不行', '不好', '不想', '不可以', '算了', '拒绝', '不玩', '没空'],
2421+
'later': ['回头', '等会', '等下', '晚点', '一会', '等等', '稍后', '过会'],
2422+
},
2423+
'en': {
2424+
# 'play' 太宽——"don't want to play" 会被 accept 误命中。改用 phrase。
2425+
# 单字 'no' 已删——即使 word-boundary 也会命中 "no idea"/"no worries"
2426+
# 等常规英文表达(CodeRabbit Major 指出)。改用 'no thanks' / 'nope' /
2427+
# 'don't want' / 'not now' 等 phrase。'after' 也太宽("after lunch"),
2428+
# 改用更长的 'after this' / 仅保留 'in a bit'/'in a minute' 等明确 later。
2429+
# 'okay' 已删——"not okay" 会被 word-boundary accept 命中且 decline 没
2430+
# 'not okay' 时 priority 救不了(codex P2 指出)。其它单词 accept ('sure'
2431+
# /'yes'/'yeah'/'yep') 同类风险靠 decline list 加 'not sure' / 'not yet'
2432+
# 等 negation phrase 双保险拦截。
2433+
# accept:"let's" 单字太宽("let's not play" 命中),改 "let's play"
2434+
# 更具体;'wanna play' 同样被 "I don't wanna play" 命中,priority 兜底
2435+
# 不可靠(之前规则已加 "don't want"),但仍保留 'wanna play' 作 accept
2436+
# phrase——decline list 同步加 "don't wanna" / "let's not" 双保险
2437+
# (CodeRabbit Major 指出后调整)。
2438+
'accept': ['yes', 'sure', "let's play", 'sounds good', 'yeah', 'yep', "i'll play", 'wanna play'],
2439+
'decline': [
2440+
'no thanks', 'nope', 'pass', 'skip',
2441+
'not now', 'not really', 'maybe not', "don't want", "don't wanna",
2442+
"let's not",
2443+
'not okay', 'not sure', 'not yet',
2444+
],
2445+
'later': ['later', 'in a bit', 'in a minute', 'in a moment', 'after this'],
2446+
},
2447+
'ja': {
2448+
# 'やる' 太宽('やめる' 含子串),换成 'やるよ'。
2449+
'accept': ['やろう', 'いいよ', 'うん', 'はい', 'やるよ', 'やります'],
2450+
'decline': ['パス', '嫌', 'いいえ', 'やめる', 'いやだ'],
2451+
'later': ['あとで', '今度', 'また今度', 'もうちょい', 'ちょっと待って'],
2452+
},
2453+
'ko': {
2454+
# '안' 太宽('안녕' / '안 그래도' 都会命中),改用 phrase。
2455+
# 单字 '응' 也宽——"적응" / "반응" 等含子串命中。codex P2 指出后删;
2456+
# 留 '좋아' / '그래' / '가자' / 'ㅇㅇ' 已 cover 接受意图。
2457+
'accept': ['좋아', '그래', '가자', 'ㅇㅇ'],
2458+
'decline': ['싫어', '아니', '됐어', '안 해'],
2459+
'later': ['나중', '이따', '잠시', '잠깐만'],
2460+
},
2461+
'ru': {
2462+
'accept': ['да', 'давай', 'конечно', 'хорошо', 'ок'],
2463+
'decline': ['нет', 'не хочу', 'откажусь', 'пас'],
2464+
'later': ['потом', 'позже', 'попозже', 'не сейчас'],
2465+
},
2466+
}
2467+
23582468
# ---------- 音乐搜索结果格式化 ----------
23592469
MUSIC_SEARCH_RESULT_TEXTS = {
23602470
'zh': {

frontend/react-neko-chat/src/App.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import {
1010
type AvatarInteractionPayload,
1111
type AvatarToolStatePayload,
1212
type GalgameOption,
13+
type ChoiceOption,
14+
type ChoicePrompt,
15+
type ChoicePromptSource,
1316
} from './message-schema';
1417

1518
export type ChatWindowProps = ChatWindowSchemaProps & {
@@ -24,6 +27,11 @@ export type ChatWindowProps = ChatWindowSchemaProps & {
2427
onTranslateToggle?: () => void;
2528
onGalgameModeToggle?: () => void;
2629
onGalgameOptionSelect?: (option: GalgameOption) => void;
30+
// Generic ChoicePrompt(mini-game invite 等通用三选项框架)。
31+
// galgame mode 现有路径继续走 galgameOptions / onGalgameOptionSelect(BC);
32+
// 本框架先只承载 mini_game_invite,未来可把 galgame 也迁过来。
33+
choicePrompt?: ChoicePrompt | null;
34+
onChoiceSelect?: (option: ChoiceOption, source: ChoicePromptSource) => void;
2735
};
2836

2937
const defaultMessages: ChatMessage[] = [];
@@ -576,6 +584,8 @@ export default function App({
576584
onTranslateToggle,
577585
onGalgameModeToggle,
578586
onGalgameOptionSelect,
587+
choicePrompt = null,
588+
onChoiceSelect,
579589
rollbackDraft,
580590
_rollbackKey,
581591
_toolCursorResetKey,
@@ -1769,6 +1779,49 @@ export default function App({
17691779
</div>
17701780
</div>
17711781
) : null}
1782+
{/*
1783+
Generic ChoicePrompt slot —— mini-game invite 等通用三选项
1784+
抽象。复用 .composer-galgame-* CSS 让 visual / animation 与
1785+
galgame mode 统一;本框架与 galgame slot 互不重叠(galgame
1786+
走自己的 galgameOptions 路径,BC),未来可统一迁移。
1787+
*/}
1788+
{choicePrompt && choicePrompt.options.length > 0 ? (
1789+
<div
1790+
className={`composer-galgame-slot composer-choice-slot is-open is-${choicePrompt.source}`}
1791+
aria-hidden="false"
1792+
data-choice-source={choicePrompt.source}
1793+
>
1794+
<div
1795+
className="composer-galgame-options composer-choice-options"
1796+
role="group"
1797+
aria-label={choicePrompt.source === 'mini_game_invite'
1798+
? i18n('chat.miniGameInviteOptionsAriaLabel', 'Mini-game invite options')
1799+
: galgameToggleButtonLabel}
1800+
>
1801+
{choicePrompt.options.slice(0, 3).map((option, index) => (
1802+
<button
1803+
key={`${index}-${option.choice}`}
1804+
type="button"
1805+
className="composer-galgame-option composer-choice-option"
1806+
title={option.label}
1807+
onClick={() => {
1808+
if (submittingRef.current) return;
1809+
submittingRef.current = true;
1810+
try {
1811+
onChoiceSelect?.(option, choicePrompt.source);
1812+
} finally {
1813+
requestAnimationFrame(() => { submittingRef.current = false; });
1814+
}
1815+
}}
1816+
>
1817+
<span className="composer-galgame-option-text composer-choice-option-text">
1818+
{option.label}
1819+
</span>
1820+
</button>
1821+
))}
1822+
</div>
1823+
</div>
1824+
) : null}
17721825
<div className="composer-bottom-bar" ref={composerBottomBarRef}>
17731826
<div className="composer-bottom-tools" aria-label={composerToolsAriaLabel}>
17741827
<button

frontend/react-neko-chat/src/message-schema.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,32 @@ const galgameOptionSchema = z.object({
5353
text: z.string().min(1),
5454
});
5555

56+
// Generic ChoicePrompt — composer-anchored "AI 给你出几个选项" UI 组件抽象。
57+
//
58+
// 当前 source:
59+
// - 'galgame' :旧路径(galgameOptions / onGalgameOptionSelect 依然
60+
// 保留 BC,本框架不替换它,作为渐进迁移目标)
61+
// - 'mini_game_invite' :mini-game 邀请三选项(accept / decline / later)
62+
//
63+
// 未来扩展:
64+
// - 'tutorial_step' / 'plugin_action' / ...
65+
// - 当需要"对话框 + avatar 旁边同步显示"时,加 placement: 'composer' | 'avatar'
66+
// | 'both',不破坏 wire-format。
67+
//
68+
// option.choice 是后端 wire-format 标识符(accept/decline/later 之类),点击
69+
// 时回传给 onChoiceSelect;UI 显示用 option.label。
70+
const choiceOptionSchema = z.object({
71+
choice: z.string().min(1), // wire id (accept/decline/later/...)
72+
label: z.string().min(1), // 显示文本
73+
});
74+
75+
const choicePromptSchema = z.object({
76+
source: z.enum(['galgame', 'mini_game_invite']),
77+
options: z.array(choiceOptionSchema).min(1),
78+
sessionId: z.string().optional(),
79+
gameType: z.string().optional(),
80+
}).nullable();
81+
5682
const avatarInteractionPayloadBaseSchema = z.object({
5783
interactionId: z.string().min(1),
5884
target: z.literal('avatar'),
@@ -228,6 +254,14 @@ export const chatWindowPropsSchema = z.object({
228254
.args(galgameOptionSchema)
229255
.returns(z.void())
230256
.optional(),
257+
// Generic ChoicePrompt(mini-game invite 等通用三选项框架)
258+
choicePrompt: choicePromptSchema.optional(),
259+
onChoiceSelect: z.function()
260+
// source 必须是固定枚举,与 ChoicePrompt['source'] 对齐——CodeRabbit 指出
261+
// 任意 z.string() 会让 zod 验证变松。
262+
.args(choiceOptionSchema, z.enum(['galgame', 'mini_game_invite']))
263+
.returns(z.void())
264+
.optional(),
231265
});
232266

233267
export type ChatMessageRole = z.infer<typeof chatMessageSchema>['role'];
@@ -239,6 +273,9 @@ export type StatusBlock = z.infer<typeof statusBlockSchema>;
239273
export type ButtonGroupBlock = z.infer<typeof buttonGroupBlockSchema>;
240274
export type ComposerAttachment = z.infer<typeof composerAttachmentSchema>;
241275
export type GalgameOption = z.infer<typeof galgameOptionSchema>;
276+
export type ChoiceOption = z.infer<typeof choiceOptionSchema>;
277+
export type ChoicePrompt = NonNullable<z.infer<typeof choicePromptSchema>>;
278+
export type ChoicePromptSource = ChoicePrompt['source'];
242279
export type AvatarInteractionPayload = z.infer<typeof avatarInteractionPayloadSchema>;
243280
export type AvatarToolStatePayload = z.infer<typeof avatarToolStatePayloadSchema>;
244281
export type MessageBlock = z.infer<typeof messageBlockSchema>;

main_logic/core.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5035,6 +5035,52 @@ async def _process_stream_data_internal(self, message: dict):
50355035
is_voice_source=False,
50365036
)
50375037

5038+
# Mini-game 邀请的关键词文本兜底(PR #1141 follow-up E2)。
5039+
# 用户在 pending 邀请期间自己打字(没点 ChoicePrompt 三按
5040+
# 钮)→ 扫关键词命中就触发对应 state 转换。**不吃掉消息**:
5041+
# 继续走普通 chat 流水线,AI 仍然会回应这条话——AI 收到的
5042+
# 上下文里也含这条用户输入,所以模型会自然把"好啊"、"不
5043+
# 玩了"之类的回复处理掉。仅做 state side effect + accept 时
5044+
# 推一条 mini_game_launch WS 让前端 window.open 游戏。
5045+
try:
5046+
from main_routers.system_router import _maybe_apply_mini_game_invite_keyword
5047+
_kw_outcome = _maybe_apply_mini_game_invite_keyword(
5048+
self.lanlan_name,
5049+
data if isinstance(data, str) else '',
5050+
)
5051+
except Exception as _kw_err:
5052+
logger.debug(
5053+
f"[{self.lanlan_name}] mini-game invite keyword "
5054+
f"matcher hook failed: {_kw_err}",
5055+
)
5056+
_kw_outcome = None
5057+
# 推一条 mini_game_invite_resolved 给前端:accept 时兼当 launch
5058+
# 信号(带 game_url),decline/later 时让 ChoicePrompt UI 清掉
5059+
# 不让按钮挂着——codex P2 指出,原版只对 accept 推,
5060+
# decline/later keyword 命中后前端 prompt 不消失,用户后续点
5061+
# 按钮会被 endpoint 当 expired,state 早变了。
5062+
if _kw_outcome and _kw_outcome.get('action'):
5063+
try:
5064+
if (self.websocket
5065+
and hasattr(self.websocket, 'send_json')):
5066+
ws_state = getattr(self.websocket, 'client_state', None)
5067+
if ws_state is None or ws_state == ws_state.CONNECTED:
5068+
payload = {
5069+
'type': 'mini_game_invite_resolved',
5070+
'session_id': _kw_outcome.get('session_id') or '',
5071+
'action': _kw_outcome['action'],
5072+
}
5073+
if _kw_outcome.get('game_url'):
5074+
payload['game_url'] = _kw_outcome['game_url']
5075+
if _kw_outcome.get('game_type'):
5076+
payload['game_type'] = _kw_outcome['game_type']
5077+
await self.websocket.send_json(payload)
5078+
except Exception as _push_err:
5079+
logger.warning(
5080+
f"[{self.lanlan_name}] mini_game_invite_resolved "
5081+
f"WS push failed: {_push_err}",
5082+
)
5083+
50385084
should_handoff, openclaw_messages = await self._should_handoff_text_to_openclaw(data)
50395085
if should_handoff:
50405086
handed_off = await self._dispatch_openclaw_handoff(data, openclaw_messages)

0 commit comments

Comments
 (0)