|
543 | 543 | // |
544 | 544 | // 单调性: 固定 M1 使 delay(T) ≈ base + T×(M1-1), |
545 | 545 | // ∂delay/∂base = 1 > 0,base 越高期望 delay 越高。 |
| 546 | + // |
| 547 | + // ── 固定间隔分支 (proactiveFixedScheduleMode) ── |
| 548 | + // 当后端 propensity=restricted_screen_only(屏幕专注态:gaming / |
| 549 | + // focused_work)时,常规退避会让搭话间隔指数级增长,跟陪伴产品 |
| 550 | + // 命题冲突。前端跳过 tier backoff,按 baseInterval 等间隔触发, |
| 551 | + // 后端在 /proactive_chat 入口注入 [0, 0.5×base] sleep 把实际间隔 |
| 552 | + // 抹成 [base, 1.5×base] 均匀分布。详见 main_routers/system_router.py |
| 553 | + // 的 restricted_screen_only 处理段。 |
546 | 554 |
|
547 | 555 | var baseInterval = S.proactiveChatInterval; |
548 | 556 | var BACKOFF_TARGET = 120; |
|
562 | 570 | var cap1 = caps.cap1; |
563 | 571 | var cap2 = caps.cap2; |
564 | 572 |
|
| 573 | + var fixedMode = !!S.proactiveFixedScheduleMode; |
565 | 574 | var level = S.proactiveChatBackoffLevel; |
566 | 575 | var delay; |
567 | 576 |
|
568 | | - if (level < cap1) { |
| 577 | + if (fixedMode) { |
| 578 | + // 屏幕专注态:跳过 tier backoff,重置 level,按 baseInterval 等间隔 |
| 579 | + // 触发。抖动完全交给后端([0, 0.5×base] sleep),前端不做乘性抖动, |
| 580 | + // 否则两层叠加会让方差大于设计目标。 |
| 581 | + S.proactiveChatBackoffLevel = 0; |
| 582 | + level = 0; |
| 583 | + delay = baseInterval * 1000; |
| 584 | + } else if (level < cap1) { |
569 | 585 | // Tier 1: base × M1^level,确定性爬升 |
570 | 586 | delay = (baseInterval * 1000) * Math.pow(BACKOFF_M1, level); |
571 | 587 | } else { |
|
576 | 592 | } |
577 | 593 |
|
578 | 594 | // 对 delay 做 ±12% 乘性随机抖动,避免节奏过于机械 |
579 | | - delay *= 1 + (Math.random() - 0.5) * 0.24; |
| 595 | + // 固定模式下抖动由后端注入,前端不再叠加 |
| 596 | + if (!fixedMode) { |
| 597 | + delay *= 1 + (Math.random() - 0.5) * 0.24; |
| 598 | + } |
580 | 599 |
|
581 | 600 | // 首次启动时额外等待 6 秒,避免程序刚启动就触发音乐推荐。 |
582 | 601 | // 用一次性 flag 而非 backoffLevel === 0 —— 后者在 user_input reset 或 |
|
589 | 608 | } |
590 | 609 | delay += startupDelay; |
591 | 610 |
|
| 611 | + // 输入放缓 floor 跟 fixed/tier 模式正交:用户在打字时不该被主动搭话打断, |
| 612 | + // 不管处于屏幕专注态还是常规态。两边都套这个下限。 |
592 | 613 | var inputSlowdownDelay = _getChatInputSlowdownDelay(baseInterval); |
593 | 614 | if (inputSlowdownDelay > 0) { |
594 | 615 | delay = Math.max(delay, inputSlowdownDelay); |
595 | 616 | } |
596 | 617 |
|
597 | | - console.log('主动搭话:' + (delay / 1000).toFixed(1) + '秒后触发(基础间隔:' + baseInterval + '秒,退避级别:' + level + ',cap1:' + cap1 + ',cap2:' + cap2 + ',启动延迟:' + (startupDelay / 1000) + '秒,输入放缓:' + (inputSlowdownDelay ? ((inputSlowdownDelay / 1000) + '秒') : '无') + ')'); |
| 618 | + if (fixedMode) { |
| 619 | + console.log('主动搭话:' + (delay / 1000).toFixed(1) + '秒后触发(屏幕专注态固定间隔,base=' + baseInterval + 's,level 已重置,后端注入抖动,启动延迟:' + (startupDelay / 1000) + '秒,输入放缓:' + (inputSlowdownDelay ? ((inputSlowdownDelay / 1000) + '秒') : '无') + ')'); |
| 620 | + } else { |
| 621 | + console.log('主动搭话:' + (delay / 1000).toFixed(1) + '秒后触发(基础间隔:' + baseInterval + '秒,退避级别:' + level + ',cap1:' + cap1 + ',cap2:' + cap2 + ',启动延迟:' + (startupDelay / 1000) + '秒,输入放缓:' + (inputSlowdownDelay ? ((inputSlowdownDelay / 1000) + '秒') : '无') + ')'); |
| 622 | + } |
598 | 623 |
|
599 | 624 | S.proactiveChatTimer = setTimeout(async function () { |
600 | 625 | // 双重检查锁:定时器触发时再次检查是否正在执行 |
|
628 | 653 | // tier 1 (level < cap1): 每次必升 — 确定性爬升阶段 |
629 | 654 | // tier 2 (cap1 ≤ level < cap2): 9% 概率升级 — 慢区,长时间停留 |
630 | 655 | // tier 3 (level ≥ cap2): 每次必升 — 快区,快速逼近 60min 硬顶 |
631 | | - if (triggered) { |
| 656 | + // 屏幕专注态固定模式下不动 level(由 next_schedule_fixed_mode |
| 657 | + // 反向通知后续 reset),让用户离开屏幕态回到常规态时 backoff |
| 658 | + // 不会带着旧值。 |
| 659 | + // |
| 660 | + // ⚠️ 用本轮调度时捕获的 ``fixedMode`` 而非已被响应同步过的 |
| 661 | + // ``S.proactiveFixedScheduleMode``:本轮的 level 推进决策应该基于 |
| 662 | + // 「这一 round 是按哪种模式调度的」而不是「返回后的最新模式」。 |
| 663 | + // 否则 fixed → tier 切换的那一跳会误升一级,下一轮 tier 不能从 |
| 664 | + // 干净的 base 起步。CodeRabbit Minor review: PR #1327。 |
| 665 | + if (triggered && !fixedMode) { |
632 | 666 | var currentCaps = computeBackoffCaps(S.proactiveChatInterval); |
633 | 667 | var currentCap1 = currentCaps.cap1; |
634 | 668 | var currentCap2 = currentCaps.cap2; |
|
714 | 748 | voiceModes.push('vision'); |
715 | 749 | } |
716 | 750 | console.log('[ProactiveChat] 语音模式快速路径,modes: [' + voiceModes.join(', ') + ']'); |
| 751 | + // 故意不带 base_interval_seconds / 不读 next_schedule_fixed_mode: |
| 752 | + // 语音模式在后端走 voice fast path(system_router.py 4222 行附近), |
| 753 | + // 在 propensity / restricted_screen_only / 抖动 sleep 这一整套门 |
| 754 | + // 之前就早退;语音 scheduler 自己也是固定 baseInterval 不带 backoff。 |
| 755 | + // 既然两边都不读,发了也是冗余字段。 |
717 | 756 | var resp = await fetch('/api/proactive_chat', { |
718 | 757 | method: 'POST', |
719 | 758 | headers: { 'Content-Type': 'application/json' }, |
|
823 | 862 | // mini-game 邀请的用户级 toggle;后端 _maybe_deliver_mini_game_invite |
824 | 863 | // 与 source-driven sources 解耦,不进 enabled_modes 数组。 |
825 | 864 | mini_game_invite_enabled: !!S.proactiveMiniGameInviteEnabled, |
826 | | - i18n_language: i18nLanguage |
| 865 | + i18n_language: i18nLanguage, |
| 866 | + // 屏幕专注态后端会按 [0, 0.5×base] 注入间隔抖动,需要知道 |
| 867 | + // 当前用户配置的 baseInterval。后端 propensity 非屏幕专注态 |
| 868 | + // 时忽略此字段。 |
| 869 | + base_interval_seconds: S.proactiveChatInterval |
827 | 870 | }; |
828 | 871 |
|
829 | 872 | // 独立计时器:确保 vision/window 模式的屏幕感知间隔不低于 proactiveVisionInterval |
|
984 | 1027 |
|
985 | 1028 | var result = await response.json(); |
986 | 1029 |
|
| 1030 | + // 同步下一轮调度模式:后端在 propensity=restricted_screen_only |
| 1031 | + // 时会把这个字段置 true,前端 scheduleProactiveChat 据此跳过 |
| 1032 | + // tier backoff、按 baseInterval 等间隔触发。 |
| 1033 | + // |
| 1034 | + // 字段缺席 ≠ 模式应该回退。短路响应路径(409 try_start_proactive |
| 1035 | + // 冲突、voice fast path、game-route active 早退)都不走 _end_proactive, |
| 1036 | + // 也就拿不到这个字段——但用户的活动状态没变,把模式硬重置成 false |
| 1037 | + // 会让一次并发冲突就把客户端踢出 fixed 模式、被 tier backoff 吞几轮。 |
| 1038 | + // 改为:只有显式收到 boolean 才同步,缺席时保留旧状态。 |
| 1039 | + // Codex P2 review: PR #1327。 |
| 1040 | + if (typeof result.next_schedule_fixed_mode === 'boolean') { |
| 1041 | + S.proactiveFixedScheduleMode = result.next_schedule_fixed_mode; |
| 1042 | + } |
| 1043 | + |
987 | 1044 | if (result.success) { |
988 | 1045 | if (result.action === 'chat') { |
989 | 1046 | console.log('主动搭话已发送:', result.message, result.source_mode ? '(来源: ' + result.source_mode + ')' : ''); |
|
0 commit comments