Skip to content

Commit 21ce320

Browse files
wehosHongzhi Wenclaude
authored
feat(proactive): 主动搭话模式/频率 API + 内置控制器插件 (#1331)
* feat(proactive): 主动搭话模式/频率 API + 内置控制器插件 为主动搭话新增稳定的 API 与一个内置系统插件 `proactive_controller`。 其他插件不再需要直接操作 conversation-settings 的一堆 `proactive*Enabled` / `proactive*Interval` 字段,只通过 `ctx.plugins.call_entry(proactive_controller:set_mode, {...})` 等就能集中 调度。 - `main_routers/proactive_router.py` 新增四个端点: - GET/POST /api/proactive/mode 套用预设 (off / normal / focus / frequent) - GET/POST /api/proactive/settings 字段子集读/部分更新 - `plugin/plugins/proactive_controller/`:新内置插件 - 入口:set_mode / set_settings / get_state / command(聚合入口) - 默认关闭:首次启动且 `proactiveChatEnabled` 字段缺失时套用 off;已有用户偏好原样保留 - i18n 覆盖 zh-CN/zh-TW/en/ja/ko/ru/es/pt - 顺带修一个单位 bug:`utils/preferences.py` 中 interval 校验从 `1000 <= v <= 3600000`(毫秒)改为 `1 <= v <= 3600`(秒),与前端 `app-state.js` 的 `proactive*Interval` 单位一致;此前前端发的 interval 值因校验单位 mismatch 被静默丢弃。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): applied 回显改为保存后回读 + 空 except 加 warning log 回应 CodeRabbit / Codex / github-code-quality 三家 reviewer 的 inline 评论: - `POST /api/proactive/mode` 与 `POST /api/proactive/settings` 的 `applied` 字段原本直接回显请求 payload,但 `save_global_conversation_settings` 还会做第二轮类型 / 范围过滤(如 `1 <= interval <= 3600`),被丢弃的字段仍会让端点返回 success,调用方会误判为生效。改成保存后回读 `aload_global_conversation_settings`,仅返回真实落盘字段;`settings` 端点额外加 `rejected` 列出被静默丢弃的字段名,避免哑失败。 - `proactive_controller` 插件 startup 中 `api_timeout_seconds` 解析失败的空 except 改为 warning log,记录原始值 + fallback 值,便于 debug。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): 隐私模式(proactiveVisionEnabled)锁为用户绝对控制权 `proactiveVisionEnabled` 是前端隐私模式开关的反面(`is_privacy_mode_enabled` == `not proactiveVisionEnabled`),涉及屏幕内容采集,必须由用户在 UI 自己决定, 插件和预设都不能越权写入。本 PR 之前 `off`/`focus` 预设会把它设为 False、 `normal`/`frequent` 设为 True,会绕过用户的隐私选择。 修复: - 引入 `_USER_OWNED_FIELDS = {proactiveVisionEnabled}` 概念,构造 `_PROACTIVE_WRITABLE_FIELDS = _PROACTIVE_FIELDS - _USER_OWNED_FIELDS`。 - 从所有 4 个预设里删掉 `proactiveVisionEnabled`,切 mode 不再改隐私选择。 - 模块导入时 self-check:预设若意外混入 user-owned 字段,直接抛 RuntimeError, 把加预设时忘了筛挡在导入阶段。 - `POST /api/proactive/settings` 显式拒绝 user-owned 字段,并通过新字段 `rejected_user_owned` 报告给调用方。 - 插件 `set_settings` 在 client 侧也做同样过滤;input_schema 和 `set_mode` 描述里也写明 vision 是用户专有,避免 LLM 试图调它。 - 读路径(GET endpoints)不变,仍返回隐私字段当前值,方便插件读取后据此 决定是否要建议用户开/关,而不是自己写。 注:`proactiveVisionChatEnabled` / `proactiveVisionInterval` 仍由插件可调, 因为隐私已被主开关 gated,副开关不构成额外隐私越权。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): applied 判定改为按值比较(修复 Codex P1) Codex P1 指出:`_readback_subset` 用 key existence 判断字段是否生效, 但磁盘上若已存在该字段的旧值,新提交的值被底层 validation 拒绝时仍会 被误标为已生效(applied 显示的是旧值而非传入值)。 改进: - helper 改名 `_readback_persisted(payload: Mapping) -> (applied, rejected)`, 按 `latest[k] == payload[k]` 比较;新旧值不一致即诊断为 rejected。 - `POST /api/proactive/mode` 也用同一 helper,并在 preset 出现 rejected 时回报——这对预设来说理论上不应发生,可作为 server 端跟进信号。 - `POST /api/proactive/settings` 同步切换;行为与之前差别:传 0 等 非法 interval 给一个磁盘上已有 interval=15 的字段时,原实现回报 applied=15(误导),新实现回报 rejected=[proactiveChatInterval]。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(proactive): preset self-check 增加 unknown-key 探测 CodeRabbit nitpick:当前 self-check 只挡 ``_USER_OWNED_FIELDS`` 泄漏, 未挡键名拼写错误(如 ``proactiveChatEnable`` 漏 d)。叠加一条 ``set(preset) - _PROACTIVE_WRITABLE_FIELDS`` 检查,让导入阶段就抛出 明确异常,而不是等用户调 set_mode 才发现字段静默被丢弃。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): readback 比较增加 type 严格校验(修复 Codex P2) Codex P2 指出:Python 中 True 等于 1、False 等于 0,所以传 int 1 给 bool 字段时——saver 用 isinstance(v, bool) 会拒,但回读 latest[k] == payload[k] 仍会让磁盘上的 True 与传入的 1 判等,重新把"已被拒绝的非法 0/1"误标成 applied。 修:抽出 _value_matches(actual, expected) helper,要求 type(actual) is type(expected) 才比较相等,彻底切断 bool/int 跨类型相等的陷阱。_readback_persisted 改用这个 helper。 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 34f4a29 commit 21ce320

13 files changed

Lines changed: 639 additions & 1 deletion

File tree

app/main_server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1466,6 +1466,7 @@ async def get_response(self, path, scope):
14661466
from main_routers.characters_router import router as characters_router # noqa
14671467
from main_routers.cloudsave_router import router as cloudsave_router # noqa
14681468
from main_routers.config_router import router as config_router # noqa
1469+
from main_routers.proactive_router import router as proactive_router # noqa
14691470
from main_routers.galgame_router import router as galgame_router # noqa
14701471
from main_routers.jukebox_router import router as jukebox_router # noqa
14711472
from main_routers.live2d_router import router as live2d_router # noqa
@@ -1512,6 +1513,7 @@ async def beacon_shutdown():
15121513

15131514
# 挂载全部路由
15141515
app.include_router(config_router)
1516+
app.include_router(proactive_router)
15151517
app.include_router(characters_router)
15161518
app.include_router(live2d_router)
15171519
app.include_router(vrm_router)

main_routers/proactive_router.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Proactive Chat Router
4+
5+
主动搭话(proactive chat)模式与频率的统一 API。
6+
7+
URL convention: 路由声明不带末尾斜杠(与 ``main_routers/config_router.py``
8+
保持一致;由 ``scripts/check_api_trailing_slash.py`` 守门)。
9+
10+
提供四个端点:
11+
12+
* ``GET /api/proactive/mode`` — 读取当前模式(off / normal / focus / frequent / custom)
13+
* ``POST /api/proactive/mode`` — 套用一组预设
14+
* ``GET /api/proactive/settings`` — 读取主动搭话相关字段当前值
15+
* ``POST /api/proactive/settings`` — 更新部分主动搭话字段(白名单内)
16+
17+
所有写入复用 ``utils.preferences.save_global_conversation_settings``,
18+
保证白名单/类型校验/原子写入逻辑只在一处维护。
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import asyncio
24+
from typing import Any, Mapping
25+
26+
from fastapi import APIRouter, Request
27+
28+
from utils.cloudsave_runtime import MaintenanceModeError
29+
from utils.logger_config import get_module_logger
30+
from utils.preferences import (
31+
aload_global_conversation_settings,
32+
save_global_conversation_settings,
33+
)
34+
35+
36+
router = APIRouter(prefix="/api/proactive", tags=["proactive"])
37+
logger = get_module_logger(__name__, "Main")
38+
39+
40+
# 用户绝对控制权 —— 插件和预设禁止越权修改的字段。
41+
# ``proactiveVisionEnabled`` 是前端"隐私模式"开关的反面
42+
# (``is_privacy_mode_enabled() == not proactiveVisionEnabled``),
43+
# 涉及屏幕内容采集,必须由用户本人在 UI 决定,任何 API 写入路径都要拒绝。
44+
_USER_OWNED_FIELDS = frozenset({
45+
"proactiveVisionEnabled",
46+
})
47+
48+
# 主动搭话所有可调字段(白名单子集;与 utils/preferences 的
49+
# _ALLOWED_CONVERSATION_SETTINGS 保持同步,但只暴露搭话相关字段)。
50+
# 注:``_PROACTIVE_FIELDS`` 仅用于**读路径**和模式反推,写路径会额外
51+
# 过滤掉 ``_USER_OWNED_FIELDS``。
52+
_PROACTIVE_BOOL_FIELDS = (
53+
"proactiveChatEnabled",
54+
"proactiveVisionEnabled",
55+
"proactiveVisionChatEnabled",
56+
"proactiveNewsChatEnabled",
57+
"proactiveVideoChatEnabled",
58+
"proactivePersonalChatEnabled",
59+
"proactiveMusicEnabled",
60+
"proactiveMemeEnabled",
61+
"proactiveMiniGameInviteEnabled",
62+
)
63+
_PROACTIVE_INT_FIELDS = (
64+
"proactiveChatInterval",
65+
"proactiveVisionInterval",
66+
)
67+
_PROACTIVE_FIELDS = _PROACTIVE_BOOL_FIELDS + _PROACTIVE_INT_FIELDS
68+
# 写路径允许的字段:从全集里剔除用户专有字段。
69+
_PROACTIVE_WRITABLE_FIELDS = frozenset(_PROACTIVE_FIELDS) - _USER_OWNED_FIELDS
70+
71+
72+
# 预设模式:服务器端定义,避免每个调用方自己维护一份。
73+
# interval 单位与前端 ``app-state.js`` 一致 —— 秒。
74+
# 注:预设故意不包含 ``proactiveVisionEnabled``(隐私模式);切换 mode
75+
# 不会改变用户的隐私选择。
76+
PROACTIVE_PRESETS: dict[str, dict[str, Any]] = {
77+
"off": {
78+
"proactiveChatEnabled": False,
79+
"proactiveVisionChatEnabled": False,
80+
"proactiveNewsChatEnabled": False,
81+
"proactiveVideoChatEnabled": False,
82+
"proactivePersonalChatEnabled": False,
83+
"proactiveMusicEnabled": False,
84+
"proactiveMemeEnabled": False,
85+
"proactiveMiniGameInviteEnabled": False,
86+
},
87+
"normal": {
88+
"proactiveChatEnabled": True,
89+
"proactiveVisionChatEnabled": True,
90+
"proactiveNewsChatEnabled": True,
91+
"proactiveVideoChatEnabled": True,
92+
"proactivePersonalChatEnabled": True,
93+
"proactiveMusicEnabled": True,
94+
"proactiveMemeEnabled": True,
95+
"proactiveMiniGameInviteEnabled": True,
96+
"proactiveChatInterval": 15,
97+
"proactiveVisionInterval": 10,
98+
},
99+
# 低打扰:保留搭话与个人动态,关掉新闻/视频/音乐等噪声源,间隔放长。
100+
# 不动 vision/隐私开关——是否允许看屏幕由用户自己决定。
101+
"focus": {
102+
"proactiveChatEnabled": True,
103+
"proactiveVisionChatEnabled": False,
104+
"proactiveNewsChatEnabled": False,
105+
"proactiveVideoChatEnabled": False,
106+
"proactivePersonalChatEnabled": True,
107+
"proactiveMusicEnabled": False,
108+
"proactiveMemeEnabled": False,
109+
"proactiveMiniGameInviteEnabled": False,
110+
"proactiveChatInterval": 60,
111+
"proactiveVisionInterval": 60,
112+
},
113+
# 高频:全开,间隔最短。
114+
"frequent": {
115+
"proactiveChatEnabled": True,
116+
"proactiveVisionChatEnabled": True,
117+
"proactiveNewsChatEnabled": True,
118+
"proactiveVideoChatEnabled": True,
119+
"proactivePersonalChatEnabled": True,
120+
"proactiveMusicEnabled": True,
121+
"proactiveMemeEnabled": True,
122+
"proactiveMiniGameInviteEnabled": True,
123+
"proactiveChatInterval": 5,
124+
"proactiveVisionInterval": 5,
125+
},
126+
}
127+
128+
# Self-check:预设里不应混入用户绝对控制权字段,也不应有拼写错误/不可写字段。
129+
# 每次模块加载时校验,把"加预设时忘了筛"和"键名打错被静默忽略"这两类回归
130+
# 都挡在导入阶段,而不是用户调 set_mode 才暴露。
131+
for _mode_name, _preset in PROACTIVE_PRESETS.items():
132+
_leaked = set(_preset.keys()) & _USER_OWNED_FIELDS
133+
if _leaked:
134+
raise RuntimeError(
135+
f"PROACTIVE_PRESETS[{_mode_name!r}] 不应包含用户专有字段: {sorted(_leaked)}"
136+
)
137+
_unknown = set(_preset.keys()) - _PROACTIVE_WRITABLE_FIELDS
138+
if _unknown:
139+
raise RuntimeError(
140+
f"PROACTIVE_PRESETS[{_mode_name!r}] 包含未知/不可写字段: {sorted(_unknown)}"
141+
)
142+
143+
144+
def _filter_proactive_subset(settings: dict[str, Any]) -> dict[str, Any]:
145+
"""从完整 conversation-settings 中挑出搭话相关字段。"""
146+
return {k: v for k, v in settings.items() if k in _PROACTIVE_FIELDS}
147+
148+
149+
def _value_matches(actual: Any, expected: Any) -> bool:
150+
"""type-aware equality:避免 Python 的 ``True == 1`` / ``False == 0`` 陷阱。
151+
152+
``save_global_conversation_settings`` 的 bool 字段校验是
153+
``isinstance(v, bool)``,会拒绝整数 ``0/1``;但若仅用 ``==`` 比较,
154+
磁盘上的 ``True`` 与传入的 ``1`` 仍会被判等,回报"已生效"——这是
155+
Codex 指出的同类问题。要求 ``type()`` 完全一致即可彻底切断。
156+
"""
157+
return type(actual) is type(expected) and actual == expected
158+
159+
160+
async def _readback_persisted(payload: Mapping[str, Any]) -> tuple[dict[str, Any], list[str]]:
161+
"""保存后回读,返回 ``(applied, rejected)``。
162+
163+
判定规则是**按值 + 按类型严格比较**:
164+
- 按值比较:``save_global_conversation_settings`` 做第二轮过滤时,被
165+
丢弃的字段会保留原磁盘旧值;若仅判断 key 是否存在,"旧值已在磁盘
166+
上 + 新值被拒"会被误标为已生效。
167+
- 按类型比较:Python 中 ``True == 1`` / ``False == 0``;传 int ``1``
168+
给 bool 字段时 saver 会拒,但磁盘 ``True`` 与传入 ``1`` 仍会
169+
``==`` 判等。``_value_matches`` 强制 ``type()`` 一致来切断这层陷阱。
170+
"""
171+
latest = await aload_global_conversation_settings()
172+
applied: dict[str, Any] = {}
173+
rejected: list[str] = []
174+
for k, v in payload.items():
175+
if k in latest and _value_matches(latest[k], v):
176+
applied[k] = latest[k]
177+
else:
178+
rejected.append(k)
179+
return applied, rejected
180+
181+
182+
def _infer_mode(settings: dict[str, Any]) -> str:
183+
"""根据当前持久化的字段反推所属预设;不匹配任何预设则返回 ``custom``。
184+
185+
比较时仅考察 preset 显式列出的字段,缺失字段视为不匹配。
186+
"""
187+
for mode_name, preset in PROACTIVE_PRESETS.items():
188+
if all(settings.get(k) == v for k, v in preset.items()):
189+
return mode_name
190+
return "custom"
191+
192+
193+
@router.get("/mode")
194+
async def get_proactive_mode():
195+
"""读取当前模式 + 当前主动搭话相关字段。"""
196+
try:
197+
settings = await aload_global_conversation_settings()
198+
subset = _filter_proactive_subset(settings)
199+
return {
200+
"success": True,
201+
"mode": _infer_mode(subset),
202+
"available_modes": list(PROACTIVE_PRESETS.keys()),
203+
"settings": subset,
204+
}
205+
except Exception as e:
206+
logger.exception(f"获取主动搭话模式失败: {e}")
207+
return {"success": False, "error": "Internal server error", "mode": "custom", "settings": {}}
208+
209+
210+
@router.post("/mode")
211+
async def set_proactive_mode(request: Request):
212+
"""套用预设模式。
213+
214+
请求体:``{"mode": "off" | "normal" | "focus" | "frequent"}``
215+
"""
216+
try:
217+
data = await request.json()
218+
if not isinstance(data, dict):
219+
return {"success": False, "error": "请求体必须为对象"}
220+
mode = data.get("mode")
221+
if not isinstance(mode, str) or mode not in PROACTIVE_PRESETS:
222+
return {
223+
"success": False,
224+
"error": f"未知模式: {mode!r};可选值: {list(PROACTIVE_PRESETS.keys())}",
225+
}
226+
227+
preset = PROACTIVE_PRESETS[mode]
228+
if not await asyncio.to_thread(save_global_conversation_settings, dict(preset)):
229+
return {"success": False, "error": "保存失败"}
230+
231+
applied, rejected = await _readback_persisted(preset)
232+
result: dict[str, Any] = {"success": True, "mode": mode, "applied": applied}
233+
if rejected:
234+
# 预设里所有字段都应是合法值;若仍出现 rejected,多半是
235+
# _ALLOWED_CONVERSATION_SETTINGS 漂移,需要 server 端跟进。
236+
result["rejected"] = rejected
237+
return result
238+
except MaintenanceModeError:
239+
raise
240+
except Exception as e:
241+
logger.exception(f"切换主动搭话模式失败: {e}")
242+
return {"success": False, "error": "Internal server error"}
243+
244+
245+
@router.get("/settings")
246+
async def get_proactive_settings():
247+
"""读取当前主动搭话相关字段(白名单内)。"""
248+
try:
249+
settings = await aload_global_conversation_settings()
250+
return {"success": True, "settings": _filter_proactive_subset(settings)}
251+
except Exception as e:
252+
logger.exception(f"获取主动搭话设置失败: {e}")
253+
return {"success": False, "error": "Internal server error", "settings": {}}
254+
255+
256+
@router.post("/settings")
257+
async def update_proactive_settings(request: Request):
258+
"""部分更新主动搭话字段。请求体仅接受 ``_PROACTIVE_WRITABLE_FIELDS``
259+
内字段;用户专有字段(``proactiveVisionEnabled`` 隐私模式)会被
260+
显式拒绝并通过 ``rejected_user_owned`` 报告,其他未识别字段静默忽略。
261+
底层 ``save_global_conversation_settings`` 还会再做一次类型 + 范围校验。"""
262+
try:
263+
data = await request.json()
264+
if not isinstance(data, dict):
265+
return {"success": False, "error": "请求体必须为对象"}
266+
267+
rejected_user_owned = sorted(set(data.keys()) & _USER_OWNED_FIELDS)
268+
payload = {k: v for k, v in data.items() if k in _PROACTIVE_WRITABLE_FIELDS}
269+
if not payload:
270+
err: dict[str, Any] = {"success": False, "error": "没有可识别的主动搭话字段"}
271+
if rejected_user_owned:
272+
err["rejected_user_owned"] = rejected_user_owned
273+
return err
274+
275+
if not await asyncio.to_thread(save_global_conversation_settings, payload):
276+
return {"success": False, "error": "保存失败"}
277+
278+
applied, rejected = await _readback_persisted(payload)
279+
result: dict[str, Any] = {"success": True, "applied": applied}
280+
if rejected:
281+
# 字段类型/范围不合法被底层丢弃,或磁盘旧值与传入值不符。
282+
# 明确告知调用方避免误判为生效。
283+
result["rejected"] = rejected
284+
if rejected_user_owned:
285+
# 用户绝对控制权字段被拒:调用方应通过 UI 引导用户自行设置。
286+
result["rejected_user_owned"] = rejected_user_owned
287+
return result
288+
except MaintenanceModeError:
289+
raise
290+
except Exception as e:
291+
logger.exception(f"更新主动搭话设置失败: {e}")
292+
return {"success": False, "error": "Internal server error"}

0 commit comments

Comments
 (0)