Commit 814192c
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
- frontend/react-neko-chat/src
- main_logic
- main_routers
- static
- templates
- tests/unit
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1344 | 1344 | | |
1345 | 1345 | | |
1346 | 1346 | | |
| 1347 | + | |
| 1348 | + | |
| 1349 | + | |
| 1350 | + | |
| 1351 | + | |
| 1352 | + | |
| 1353 | + | |
| 1354 | + | |
| 1355 | + | |
| 1356 | + | |
| 1357 | + | |
| 1358 | + | |
| 1359 | + | |
| 1360 | + | |
| 1361 | + | |
1347 | 1362 | | |
1348 | 1363 | | |
1349 | 1364 | | |
| |||
1641 | 1656 | | |
1642 | 1657 | | |
1643 | 1658 | | |
| 1659 | + | |
| 1660 | + | |
1644 | 1661 | | |
1645 | 1662 | | |
1646 | 1663 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2355 | 2355 | | |
2356 | 2356 | | |
2357 | 2357 | | |
| 2358 | + | |
| 2359 | + | |
| 2360 | + | |
| 2361 | + | |
| 2362 | + | |
| 2363 | + | |
| 2364 | + | |
| 2365 | + | |
| 2366 | + | |
| 2367 | + | |
| 2368 | + | |
| 2369 | + | |
| 2370 | + | |
| 2371 | + | |
| 2372 | + | |
| 2373 | + | |
| 2374 | + | |
| 2375 | + | |
| 2376 | + | |
| 2377 | + | |
| 2378 | + | |
| 2379 | + | |
| 2380 | + | |
| 2381 | + | |
| 2382 | + | |
| 2383 | + | |
| 2384 | + | |
| 2385 | + | |
| 2386 | + | |
| 2387 | + | |
| 2388 | + | |
| 2389 | + | |
| 2390 | + | |
| 2391 | + | |
| 2392 | + | |
| 2393 | + | |
| 2394 | + | |
| 2395 | + | |
| 2396 | + | |
| 2397 | + | |
| 2398 | + | |
| 2399 | + | |
| 2400 | + | |
| 2401 | + | |
| 2402 | + | |
| 2403 | + | |
| 2404 | + | |
| 2405 | + | |
| 2406 | + | |
| 2407 | + | |
| 2408 | + | |
| 2409 | + | |
| 2410 | + | |
| 2411 | + | |
| 2412 | + | |
| 2413 | + | |
| 2414 | + | |
| 2415 | + | |
| 2416 | + | |
| 2417 | + | |
| 2418 | + | |
| 2419 | + | |
| 2420 | + | |
| 2421 | + | |
| 2422 | + | |
| 2423 | + | |
| 2424 | + | |
| 2425 | + | |
| 2426 | + | |
| 2427 | + | |
| 2428 | + | |
| 2429 | + | |
| 2430 | + | |
| 2431 | + | |
| 2432 | + | |
| 2433 | + | |
| 2434 | + | |
| 2435 | + | |
| 2436 | + | |
| 2437 | + | |
| 2438 | + | |
| 2439 | + | |
| 2440 | + | |
| 2441 | + | |
| 2442 | + | |
| 2443 | + | |
| 2444 | + | |
| 2445 | + | |
| 2446 | + | |
| 2447 | + | |
| 2448 | + | |
| 2449 | + | |
| 2450 | + | |
| 2451 | + | |
| 2452 | + | |
| 2453 | + | |
| 2454 | + | |
| 2455 | + | |
| 2456 | + | |
| 2457 | + | |
| 2458 | + | |
| 2459 | + | |
| 2460 | + | |
| 2461 | + | |
| 2462 | + | |
| 2463 | + | |
| 2464 | + | |
| 2465 | + | |
| 2466 | + | |
| 2467 | + | |
2358 | 2468 | | |
2359 | 2469 | | |
2360 | 2470 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
13 | 16 | | |
14 | 17 | | |
15 | 18 | | |
| |||
24 | 27 | | |
25 | 28 | | |
26 | 29 | | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
27 | 35 | | |
28 | 36 | | |
29 | 37 | | |
| |||
576 | 584 | | |
577 | 585 | | |
578 | 586 | | |
| 587 | + | |
| 588 | + | |
579 | 589 | | |
580 | 590 | | |
581 | 591 | | |
| |||
1769 | 1779 | | |
1770 | 1780 | | |
1771 | 1781 | | |
| 1782 | + | |
| 1783 | + | |
| 1784 | + | |
| 1785 | + | |
| 1786 | + | |
| 1787 | + | |
| 1788 | + | |
| 1789 | + | |
| 1790 | + | |
| 1791 | + | |
| 1792 | + | |
| 1793 | + | |
| 1794 | + | |
| 1795 | + | |
| 1796 | + | |
| 1797 | + | |
| 1798 | + | |
| 1799 | + | |
| 1800 | + | |
| 1801 | + | |
| 1802 | + | |
| 1803 | + | |
| 1804 | + | |
| 1805 | + | |
| 1806 | + | |
| 1807 | + | |
| 1808 | + | |
| 1809 | + | |
| 1810 | + | |
| 1811 | + | |
| 1812 | + | |
| 1813 | + | |
| 1814 | + | |
| 1815 | + | |
| 1816 | + | |
| 1817 | + | |
| 1818 | + | |
| 1819 | + | |
| 1820 | + | |
| 1821 | + | |
| 1822 | + | |
| 1823 | + | |
| 1824 | + | |
1772 | 1825 | | |
1773 | 1826 | | |
1774 | 1827 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
53 | 53 | | |
54 | 54 | | |
55 | 55 | | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
56 | 82 | | |
57 | 83 | | |
58 | 84 | | |
| |||
228 | 254 | | |
229 | 255 | | |
230 | 256 | | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
231 | 265 | | |
232 | 266 | | |
233 | 267 | | |
| |||
239 | 273 | | |
240 | 274 | | |
241 | 275 | | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
242 | 279 | | |
243 | 280 | | |
244 | 281 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5035 | 5035 | | |
5036 | 5036 | | |
5037 | 5037 | | |
| 5038 | + | |
| 5039 | + | |
| 5040 | + | |
| 5041 | + | |
| 5042 | + | |
| 5043 | + | |
| 5044 | + | |
| 5045 | + | |
| 5046 | + | |
| 5047 | + | |
| 5048 | + | |
| 5049 | + | |
| 5050 | + | |
| 5051 | + | |
| 5052 | + | |
| 5053 | + | |
| 5054 | + | |
| 5055 | + | |
| 5056 | + | |
| 5057 | + | |
| 5058 | + | |
| 5059 | + | |
| 5060 | + | |
| 5061 | + | |
| 5062 | + | |
| 5063 | + | |
| 5064 | + | |
| 5065 | + | |
| 5066 | + | |
| 5067 | + | |
| 5068 | + | |
| 5069 | + | |
| 5070 | + | |
| 5071 | + | |
| 5072 | + | |
| 5073 | + | |
| 5074 | + | |
| 5075 | + | |
| 5076 | + | |
| 5077 | + | |
| 5078 | + | |
| 5079 | + | |
| 5080 | + | |
| 5081 | + | |
| 5082 | + | |
| 5083 | + | |
5038 | 5084 | | |
5039 | 5085 | | |
5040 | 5086 | | |
| |||
0 commit comments