feat: Quick Actions Panel — 快捷操作面板#920
Conversation
…eld markers
- Add PluginSettings(BaseModel) and SettingsField(hot=True) in plugin/sdk/plugin/settings.py
- Add self.settings and self.effective_config properties to NekoPluginBase
- Auto-refresh settings on config_change lifecycle event in plugin host
- Add hot field validation to hot_update_service (temporary mode only)
- Add GET /plugin/{plugin_id}/settings/schema endpoint
- Add 'chat_command' to EntryKind Literal type
- Add .kiro/ to .gitignore
- 新增 QuickActionsPanel React 组件(按功能/按插件双视图) - 后端 actions 模块:aggregation / execution / settings / system / list_actions providers - ActionDescriptor 模型及 execute API 路由 - 前端样式(qa-* 类名体系,暗色主题,动画) - bridge 层集成:按需加载、执行回调
- 修复 TS 编译错误:message-schema 类型断言、PluginCard 未使用参数 - 根据 entry kind 区分控件类型:service → toggle,其余 → button - list_actions 统一为 button(移除 _TRIGGER_KINDS 映射表) - execution_service 重构为 handler 模式,提取 plugin_settings_resolver - 新增 actions_proxy_router 代理到插件服务器 - 新增 button 控件类型(action_models + 前端 ButtonControl) - 工具栏图标从 emoji 替换为统一配色 PNG(quick_actions_icon.png) - CSS 类名体系从 quick-actions-* 缩短为 qa-* - 面板视图从 4-tab 改为双视图模式(按功能/按插件)+ 子标签筛选
停止插件后启动按钮仍为 disabled 的原因:executeChatAction 只更新 被执行的单个 action,但 start/stop/reload/toggle 的 disabled 状态 是联动的。改为执行成功后调用 fetchChatActions() 重新拉取全部 actions,所有按钮的 disabled/current_value 自动同步。
根因:_entry_toggle 从 state.plugin_instances 取插件实例,但该 dict 从未被写入,永远为空。 修复: - button 类 entry(value=null)直接返回成功,不做 enable/disable - toggle 类 entry(value=bool)改用 plugin_hosts 检查运行状态并 调用 host 上的 enable_entry/disable_entry
plugin_instances 是 state 上的死字段(声明但从未写入),导致: - settings_provider 读不到 effective_config,设置项无法显示当前值 - execution_service 的 entry_toggle 永远报 PLUGIN_NOT_RUNNING 统一改为: - settings_provider: 直接从 TOML 配置文件读取当前值(load_plugin_config) - execution_service: 通过 plugin_hosts 获取 host 对象操作 entry
将每个插件的 4 个独立 action(启动/停止/重载/plugin_toggle)合并为 1 个 plugin_lifecycle 复合控件: - 左侧:状态指示灯 + 插件名称 - 中间:启动/停止开关(toggle → start/stop) - 右侧:重载按钮(↻) 后端:system_provider 只生成一个 control=plugin_lifecycle 的 action 前端:新增 PluginLifecycleControl 组件 + qa-lifecycle-* CSS
- backdrop-filter blur + saturate 毛玻璃效果 - 渐变背景 + 内发光边框(inset box-shadow) - hover 时微浮起 + 蓝色光晕扩散 - 展开时加深背景 + 增强阴影层次 - 暗色主题:深色渐变底 + 微光边框 - 圆角加大到 14px,间距微调
单元测试: - test_action_models: ActionDescriptor 各控件类型、序列化、校验(15) - test_action_system_provider: 生命周期/entry kind 分流/静态UI/过滤(14) - test_action_list_actions_provider: kind 映射/收集/过滤/边界(14) - test_action_execution_service: 系统/entry/settings/list dispatch(15) - test_action_aggregation_service: 多 provider 合并/容错/过滤(4) 集成测试: - test_action_routes: GET/POST 路由、序列化、错误映射(8) 覆盖:action_models / system_provider / list_actions_provider / execution_service / aggregation_service / routes/actions
新增 17 个 vitest 测试用例: - 面板渲染、默认视图、视图切换、Escape 关闭、搜索过滤 - PluginLifecycleControl: toggle/reload 联动、disabled 状态 - ToggleControl / ButtonControl / InjectButton / NavButton 回调 - SliderControl 渲染、错误提示显示 CI: analyze.yml 新增 frontend job(npm ci → typecheck → vitest)
执行 action 后在面板底部弹出结果气泡:
- 成功:tone-success 绿色气泡,显示「{label}: 操作成功」
- 失败:tone-error 红色气泡,显示「{label}: {错误信息}」
- 最多同时显示 3 条,3 秒后自动消失
- 复用 message-block-status 的 tone-* 配色(info/success/error)
- backdrop-filter 毛玻璃 + 滑入动画
后端:
- button 类 entry 不再假返回成功,改为调用 host.trigger(entry_id, {})
通过 IPC 在插件子进程中真正执行
- 插件未运行时 button entry 也正确报 PLUGIN_NOT_RUNNING
前端 bridge:
- data.success=false 时抛出错误(之前返回 null 导致前端误判为成功)
- data.success=true 但无 action 时也正确返回 null(不再要求 action 非空)
测试:
- 新增 test_button_entry_triggers_via_host 验证 IPC 调用
- 新增 test_button_entry_plugin_not_running 验证未运行检查
需要参数的 entry(有 input_schema): - 点击执行按钮后展开参数表单,根据 schema properties 生成输入框 - 支持 string/number/integer/boolean 类型自动转换 - Enter 提交,取消/确认执行按钮 - 无参数的 entry 仍然直接执行 具体错误提示: - bridge 层解析 HTTP 错误响应体的 detail 字段,不再显示 'HTTP 400' - 前端 toast 清理 prefix,直接显示后端错误消息 - data.success=false 时抛出 data.message 作为错误 后端: - ActionDescriptor 新增 input_schema 字段 - system_provider 传递 entry 的 input_schema(仅有 properties 时) - execution_service 接受 dict value 作为 trigger 参数
1. 删除 settings_provider._import_settings_class(deprecated 重复逻辑) 统一使用 infrastructure.plugin_settings_resolver.resolve_settings_class 2. 移除 lifecycle/entry/profile handler 中的 _find_action 调用 前端已通过 fetchChatActions() 全量刷新,无需每次执行后 re-aggregate _find_action 仅保留给 settings handler(需要返回更新后的 current_value) 3. _SystemActionHandler 不再依赖 ActionAggregationService 构造函数参数从 (lifecycle, aggregation) 简化为 (lifecycle)
- CommandPalette: 行图标加背景容器、badge 替代纯文本 hint、 section 分隔线、搜索清除按钮、空状态区分搜索/无数据、 context menu 改为竖三点、slider 加粗、param form 加背景 - 新增 loading prop:首次打开时显示 spinner - host 层: _actionsLoading 状态跟踪 fetch 进度 - 新增 CommandPalette.test.tsx (23 cases) - 删除 QuickActionsPanel.tsx + QuickActionsPanel.test.tsx - CSS: 完整重写 cp-* 样式 + 暗色模式
Walkthrough本 PR 引入命令面板(Quick Actions)子系统:前端组件与样式、主服代理与插件服路由/服务、域模型与持久化、插件设置热更新校验与宿主刷新支持,并补充大量单元/集成测试喵。 Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant User as User
participant Frontend as React ChatWindow
participant MainAPI as Main Server (/chat/actions)
participant PluginServer as Plugin Server API
participant PluginHost as Plugin Host
User->>Frontend: 打开或搜索命令面板
Frontend->>MainAPI: GET /chat/actions (可含 plugin_id)
MainAPI->>PluginServer: 代理转发请求
PluginServer->>PluginHost: 查询插件元数据/设置/条目
PluginHost-->>PluginServer: 返回动作列表
PluginServer-->>MainAPI: 返回动作 + 偏好
MainAPI-->>Frontend: 响应动作与偏好
User->>Frontend: 选择并执行动作
Frontend->>MainAPI: POST /chat/actions/{actionId}/execute (value)
MainAPI->>PluginServer: 转发执行请求
PluginServer->>PluginHost: 触发 entry / 更新设置 / 生命周期 操作
PluginHost-->>PluginServer: 执行结果
PluginServer-->>MainAPI: 返回执行响应
MainAPI-->>Frontend: 返回结果并更新 recent 偏好
Estimated code review effort🎯 4 (Complex) | ⏱️ ~80 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…s-sdk # Conflicts: # .gitignore # plugin/server/http_app.py # plugin/server/routes/__init__.py
There was a problem hiding this comment.
Actionable comments posted: 18
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
plugin/core/host.py (1)
435-475:⚠️ Potential issue | 🟠 Major配置缓存和
PluginSettings刷新要保持原子喵Line 442 吞掉
_refresh_settings()异常后会继续执行并可能返回成功,此时ctx._effective_config已经是新配置,但插件实例上的 settings 还是旧值,config_changehandler 和后续 action 会看到“半更新”状态喵。建议刷新失败时回滚ctx._effective_config并返回失败,或者至少不要继续触发生命周期回调喵。🐾 建议把 settings 刷新失败纳入回滚路径喵
# 自动刷新 PluginSettings 实例(如果插件定义了 Settings) if plugin_instance is not None: _refresh = getattr(plugin_instance, "_refresh_settings", None) if callable(_refresh): try: _refresh() logger.debug("[Plugin Process] PluginSettings refreshed after config update") except Exception as _settings_err: - logger.warning( - "[Plugin Process] PluginSettings refresh failed (non-fatal): {}", - _settings_err, - ) + logger.warning( + "[Plugin Process] PluginSettings refresh failed, rolling back config: {}", + _settings_err, + ) + ctx._effective_config = old_config + try: + _refresh() + except Exception: + logger.debug("[Plugin Process] PluginSettings rollback refresh failed", exc_info=True) + ret_payload["error"] = f"PluginSettings refresh failed: {_settings_err}" + res_sender.put(ret_payload, timeout=10.0) + return🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@plugin/core/host.py` around lines 435 - 475, The PluginSettings refresh failure is currently swallowed in the plugin_instance _refresh_settings block so code proceeds with ctx._effective_config updated while the plugin instance still has old settings; modify the _refresh_settings exception handler in the block that calls plugin_instance._refresh_settings so that on any exception you roll back ctx._effective_config to old_config, log the rollback and either re-raise or return/fail early (i.e., do not continue to trigger config_change lifecycle); ensure the same unique symbols are used (plugin_instance, _refresh_settings, ctx._effective_config, config_change_handler) so the lifecycle callback only runs when settings refresh succeeds.
♻️ Duplicate comments (2)
plugin/server/application/config/hot_update_service.py (1)
159-194: 🛠️ Refactor suggestion | 🟠 Major这段解析逻辑跟新引入的
resolve_settings_class撞车了喵
plugin/server/infrastructure/plugin_settings_resolver.py的存在价值就是消除这类复制粘贴,但本函数把split(":") → import_module → getattr("Settings") → isinstance/issubclass(PluginSettings)整套流程又抄了一遍,以后任一侧改了回退规则(比如支持entry字段、或者不同的失败日志行为)两边就会漂移喵。建议直接调用 resolver(详细 diff 见
plugin_settings_resolver.py处的根因评论),这里就变成几行:settings_cls = resolve_settings_class(plugin_id, host=host) if settings_cls is None: return hot_fields = get_hot_fields(settings_cls) ...注意 resolver 已经内部校验了
PluginSettings子类关系,所以调用点连那层issubclass都不用再写了喵~🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@plugin/server/application/config/hot_update_service.py` around lines 159 - 194, The _validate_hot_fields function currently reimplements entry_point parsing and class import logic that duplicates plugin_settings_resolver; replace that block by calling resolve_settings_class(plugin_id, host=host) to obtain settings_cls, return if None, then call get_hot_fields(settings_cls) and proceed — remove the manual importlib split/getattr/issubclass checks and the related debug-return paths; rely on resolve_settings_class to validate PluginSettings subclass and fall back behavior.plugin/server/routes/config.py (1)
239-297: 🛠️ Refactor suggestion | 🟠 Major又一处 entry_point 解析复制粘贴,喵
_import_settings_class和_resolve_plugin_settings_schema里的 entry_point 处理跟plugin/server/infrastructure/plugin_settings_resolver.py::resolve_settings_class几乎一样(仅细节差异:resolver 还能从 plugins snapshot 回退,并且内部完成了PluginSettings子类校验)。直接复用会让这里变短又更健壮喵~另外,4 个分支各自返回同一个"全 null"响应字典,可以抽成一个小 helper 或者统一放在函数末尾,不然以后往响应里加字段要改 4 处——本喵懒得改这么多次的啦喵。
♻️ 建议改法
-def _resolve_plugin_settings_schema(plugin_id: str) -> dict[str, object]: - from plugin.core.state import state - from plugin.sdk.plugin.settings import PluginSettings - - with state.acquire_plugin_hosts_read_lock(): - host = state.plugin_hosts.get(plugin_id) - - if host is None: - return {...null...} - - entry_point: str | None = getattr(host, "entry_point", None) - if not entry_point: - return {...null...} - - settings_cls = _import_settings_class(entry_point) - if settings_cls is None or not (isinstance(settings_cls, type) and issubclass(settings_cls, PluginSettings)): - return {...null...} - - toml_section = settings_cls.model_config.get("toml_section", "settings") - return { - "plugin_id": plugin_id, - "settings_class": settings_cls.__name__, - "toml_section": toml_section, - "schema": settings_cls.model_json_schema(), - } +def _resolve_plugin_settings_schema(plugin_id: str) -> dict[str, object]: + from plugin.core.state import state + from plugin.server.infrastructure.plugin_settings_resolver import resolve_settings_class + + empty = { + "plugin_id": plugin_id, + "settings_class": None, + "toml_section": None, + "schema": None, + } + + with state.acquire_plugin_hosts_read_lock(): + host = state.plugin_hosts.get(plugin_id) + + settings_cls = resolve_settings_class(plugin_id, host=host) + if settings_cls is None: + return empty + + return { + "plugin_id": plugin_id, + "settings_class": settings_cls.__name__, + "toml_section": settings_cls.model_config.get("toml_section", "settings"), + "schema": settings_cls.model_json_schema(), + }然后
_import_settings_class和顶部的import importlib都可以整个删掉喵~🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@plugin/server/routes/config.py` around lines 239 - 297, The function _resolve_plugin_settings_schema duplicates entry_point parsing and validation done by resolve_settings_class in plugin/server/infrastructure/plugin_settings_resolver.py; replace the local import/parse logic by calling resolve_settings_class(entry_point) (which already falls back to plugins snapshot and ensures a PluginSettings subclass), remove the helper _import_settings_class and any importlib import, and consolidate the repeated "all-null" response dictionaries into a single helper or a single return path at the end of _resolve_plugin_settings_schema so future fields only need one change; keep existing uses of state and host lookup intact and ensure the returned keys ("plugin_id","settings_class","toml_section","schema") are produced the same way when a settings class is found (use settings_cls.__name__, settings_cls.model_config.get("toml_section","settings"), settings_cls.model_json_schema()).
🧹 Nitpick comments (4)
plugin/server/application/actions/settings_provider.py (1)
219-230: TOML 读配置的异常全部静默咽下喵,排查问题会很痛苦哦。Line 229 的
except Exception: pass会把配置文件损坏、权限问题、schema 异常等全部吞掉,然后current_value会退回到字段的default,前端展示的 slider/toggle 当前值就和 TOML 里实际的值对不上啦喵~不强求往上抛,但至少logger.debug(...)记一下,出问题时有条线索可以抓:- except Exception: - pass + except Exception as exc: + logger.debug( + "Failed to load current TOML for plugin {}: {}", pid, str(exc) + )🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@plugin/server/application/actions/settings_provider.py` around lines 219 - 230, The except block that swallows all exceptions when calling load_plugin_config (affecting current_section/toml_section) should log the error instead of being silent: in the try/except around load_plugin_config(pid, validate=False) (the block that populates current_section) catch Exception as e and call the module logger (e.g., logger.debug or logger.exception) with contextual info including pid and toml_section and the exception message/traceback so TOML parse/permission/schema errors are recorded for debugging while preserving the fallback behavior.plugin/server/routes/actions.py (1)
82-88:touch_recent失败悄咪咪的喵,debug 时会很困惑哦~"non-critical" 这个定位没错,不应该让
execute响应因为 recent 列表更新失败就返回 5xx。但这里except Exception: pass连一行日志都不记,将来用户反馈"最近使用一直没东西"的时候,你根本不知道是前端没读、还是磁盘写炸、还是 lock 卡住了喵~if result.success: try: await preferences_service.touch_recent(action_id) - except Exception: - pass # Non-critical — don't fail the execute response + except Exception as exc: + logger.debug( + "touch_recent failed for action_id={}: {}", action_id, str(exc) + )顺便一提,本喵在
preferences_service.py那边已经反馈了_save_sync静默吞异常的问题——如果那边改成会正常抛异常,这里的 debug 日志就能真的派上用场啦喵~🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@plugin/server/routes/actions.py` around lines 82 - 88, The silent except in the execute path hides failures from preferences_service.touch_recent(action_id); instead of "except Exception: pass" catch the exception as e and log it (e.g., logger.warning/exception) with context including action_id and that touch_recent failed so debugging is possible while keeping the execute response non-failing; update the block around touch_recent in execute to "except Exception as e: logger.exception(...)" (or logger.warning with the exception) and do not re-raise.plugin/tests/unit/server/test_action_execution_service.py (2)
156-224: 直接改写real_state.plugin_hosts好几处,拎成 fixture 会更整洁喵~
from plugin.core.state import state as real_state+original_hosts = real_state.plugin_hosts+try/finally这套样板在 4 个测试里粘贴了 4 次,读起来有点啰嗦哼~而且 Line 216-224 的test_toggle_entry_plugin_not_running虽然没直接设置plugin_hosts,但上一个测试如果 finally 没跑到就会污染它,全靠 pytest 顺序喵。建议拆一个
@pytest.fixture来集中处理,既 DRY 又能保证每个用例都拿到干净的 state:`@pytest.fixture` def patch_plugin_hosts(): from plugin.core.state import state as real_state original = real_state.plugin_hosts def _set(hosts): real_state.plugin_hosts = hosts yield _set real_state.plugin_hosts = original然后测试里直接
patch_plugin_hosts({"demo": host})就够啦~🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@plugin/tests/unit/server/test_action_execution_service.py` around lines 156 - 224, Extract the repeated setup/teardown that mutates real_state.plugin_hosts into a pytest fixture named patch_plugin_hosts that captures original = real_state.plugin_hosts, yields a setter function _set(hosts) which assigns real_state.plugin_hosts = hosts, and restores original after yield; then replace the try/finally blocks in tests test_button_entry_triggers_via_host, test_button_entry_plugin_not_running, test_toggle_entry_enable, and test_toggle_entry_disable by calling patch_plugin_hosts({"demo": host}) (or patch_plugin_hosts({}) for the not-running case) at the start of each test so each test uses the fixture to set and automatically restore real_state.plugin_hosts and avoid cross-test pollution.
70-87: 用__new__绕开__init__来构造 service 喵——能跑但挺脆的哦~
ActionExecutionService.__new__(...)+ 手动塞_lifecycle/_aggregation/_settings_handler/_system_handler/_list_action_handler这五个私有属性,本质上是把测试和ActionExecutionService.__init__的实现细节死绑在一起。之后真实构造器里多注入一个 handler(比如新增_NavigationHandler),测试这边会在运行时炸AttributeError,而且错误信息跟被测行为毫无关系喵~如果 service 有默认 DI 入口(比如通过构造器参数注入 lifecycle / aggregation),优先走构造器;没有的话也可以考虑给 service 加个
@classmethod for_testing(...)工厂。不是阻塞问题,但后续加 handler 的时候记得回来更新这里啦。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@plugin/tests/unit/server/test_action_execution_service.py` around lines 70 - 87, 当前测试通过 ActionExecutionService.__new__ 绕过 __init__ 并手动注入私有属性(_lifecycle, _aggregation, _settings_handler, _system_handler, _list_action_handler),这会把测试和实现细节耦合且易碎;请修改 _build_service 让它使用 ActionExecutionService 的标准构造器(传入 lifecycle/aggregation 等依赖)或者在 ActionExecutionService 中新增一个明确的工厂方法(例如类方法 for_testing(lifecycle, aggregation))来构造并初始化所有内部 handler(包括现有的 _SettingsActionHandler、_SystemActionHandler、_ListActionHandler 及将来可能新增的如 _NavigationHandler),然后在测试中改为调用该构造器/工厂,从而避免直接操作私有属性并确保未来添加 handler 不会导致 AttributeError。
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@frontend/react-neko-chat/src/App.tsx`:
- Around line 1402-1414: The quick actions toggle button is missing ARIA state
so screen readers can't tell whether it opens the panel; update the button (the
element that uses quickActionsPanelOpen, setQuickActionsPanelOpen and
onQuickActionsRequest) to include appropriate attributes: add
aria-expanded={quickActionsPanelOpen} and aria-pressed={quickActionsPanelOpen}
and include aria-controls pointing to the panel's id (match the panel id used
elsewhere) so assistive tech knows what is being toggled. Ensure these
attributes update based on quickActionsPanelOpen and keep existing
aria-label/title usage.
- Around line 1158-1164: The handleQuickActionNavigate function should validate
and sanitize the target before navigating and ensure new windows can't access
window.opener; update handleQuickActionNavigate to first reject targets with
javascript: or other non-http(s) protocols (allow only http:, https: or
site-relative paths that start with '/') by parsing with the URL constructor
(fallback to treating a leading '/' as relative), and then: for same_tab set
window.location.href only after validation; for new tab call
window.open(validTarget, '_blank', 'noopener,noreferrer') and as a defensive
fallback set const w = window.open(...); if (w) w.opener = null; this touches
the handleQuickActionNavigate handler and the target/openIn logic to prevent XSS
and mitigate window.opener attacks.
In `@frontend/react-neko-chat/src/CommandPalette.tsx`:
- Line 15: handleRowClick currently only calls onExec for control === 'button'
and allows lifecycle rows to appear clickable but do nothing and can be
double-triggered when loading; update handleRowClick to treat control ===
'plugin_lifecycle' the same as 'button' (invoke item.onExec or the shared onExec
handler) and add an early return when item.disabled || item.loading to block
clicks; also ensure you guard against double-execution by checking/setting a
loading flag (item.loading or a local in-flight guard) before calling onExec so
plugin lifecycle actions aren’t retriggered while loading.
- Around line 132-153: The range input currently only calls commit
onMouseUp/onTouchEnd so keyboard adjustments never trigger onExec; update the
input handlers to also call commit on keyboard and focus finish events (add
onKeyUp that calls commit when Enter or Space is pressed and add onBlur to call
commit when focus leaves). Use the existing commit function and the same symbols
(local, committed.current, onExec, item.action_id) so keyboard changes will set
committed.current and invoke onExec just like mouse/touch interactions.
- Around line 173-190: The onChange handler currently calls
commit(Number(e.target.value)) directly which turns empty strings into 0 and
allows out-of-range/NaN values to be submitted; update the input handling in
CommandPalette's numeric control so onChange first reads the raw string, treats
empty string as null/undefined (update local state to the empty string without
committing), parses to a number safely (guard against NaN), then clamps the
parsed value to the defined item.min/item.max (use item.min ?? -Infinity and
item.max ?? Infinity) and only then calls commit(clampedValue) and
setLocal(clampedValue); also ensure the inc and dec helpers use the same clamp
logic and respect item.step, item.min, item.max, item.disabled, and loading when
computing n before calling setLocal/commit.
- Around line 487-494: Toast 通知目前渲染为普通 div,屏幕阅读器不会可靠播报结果;在 ToastStack 中根据
ToastItem 的 tone(访问 t.tone)为每条 toast 添加无障碍属性:将错误/失败(例如 tone === 'error')标记为
role="alert" 或 aria-live="assertive"、aria-atomic="true",将成功/信息(例如 tone ===
'success' 或 'info')标记为 aria-live="polite"、aria-atomic="true",并确保用于文本的元素(使用
t.text 和 className 包含 cp-toast)不是 aria-hidden;在 ToastStack 渲染逻辑(函数
ToastStack)内为每个映射项设置这些属性以便屏读器能即时播报。
In `@frontend/react-neko-chat/src/styles.css`:
- Around line 2300-2302: The media query for prefers-reduced-motion currently
only disables animations for .cp-panel, .cp-stagger, and .cp-toast but misses
several animated elements; update the same media query to also opt-out
.cp-spinner (and its animation cp-spin), .cp-err (cp-err-flash), .cp-ctx-menu,
and .cp-param-form (cp-fade-in) by setting animation: none !important and
transition: none !important (and if present, remove transform-based
spinning/rotation) for those selectors so the spinner and other flashes/fades
are fully disabled when reduced motion is requested.
In `@main_routers/actions_proxy_router.py`:
- Around line 30-45: The handler proxy_chat_actions (and other routes that
currently do `return resp.json()`) is swallowing upstream HTTP status codes;
change logic to check resp.status_code and forward that status and body instead
of always returning 200. Create a shared httpx.AsyncClient (e.g., module-level
_client) initialized on app lifespan and closed with await _client.aclose() on
shutdown, replace per-request `async with httpx.AsyncClient(...)` usage with the
shared _client, and swap `return resp.json()` to a single helper call (suggested
name `_forward(resp)`) that builds a FastAPI Response using resp.status_code,
resp.headers (filtered), and resp.content/resp.json as the body so upstream
4xx/5xx pass through unchanged; ensure proxy_chat_actions uses _PLUGIN_BASE for
the URL and uses _client.get(...).
In `@plugin/sdk/plugin/base.py`:
- Around line 77-81: The current check uses truthiness on
self.ctx._effective_config so an empty dict is treated as "no cache" and
triggers _load_effective_config_sync; change the guard to explicitly check for
None (e.g., if cached is not None and isinstance(cached, dict)) so an empty dict
remains a valid cached value; keep returning dict(cached) and leave the fallback
to _load_effective_config_sync unchanged.
In `@plugin/sdk/plugin/settings.py`:
- Around line 140-148: The final fallback call settings_cls.model_validate({})
inside create_settings_safe can still raise ValidationError and crash plugin
startup; change this block to attempt the empty-default validation inside its
own try/except and, if it fails, raise a clear, explicit error (e.g., a
ValueError or re-raised ValidationError) that includes the original exception
and context ("PluginSettings cannot be safely constructed with defaults")
instead of letting an unhandled error propagate, or alternatively enforce that
PluginSettings fields are default-constructible before doing the fallback;
reference settings_cls.model_validate, the except ValidationError as exc
handler, and create_settings_safe when making the change.
- Around line 43-68: 在 SettingsField 中兼容 pydantic v2 的 json_schema_extra
callable:检测 kwargs.pop("json_schema_extra", None) 的类型——如果是 dict,就像现在一样在该 dict
上设置 json_schema_extra["hot"]=hot;如果是 callable(Callable[[JsonDict],
None]),则创建并传入一个新的 callable 包裹器,该包装器在被调用时先调用原始 callable(json_dict) 再设置
json_dict["hot"]=hot(或在原始 callable 后确保键存在),然后将处理后的 json_schema_extra 传入
Field(...); 保持 SettingsField、json_schema_extra 和 hot 的命名以便定位。
In `@plugin/server/application/actions/execution_service.py`:
- Around line 274-325: The code currently returns success even when host lacks
the implementation for the requested operation; update the trigger and toggle
branches so that if getattr(host, "trigger") is None you raise a
ServerDomainError (e.g. code="ENTRY_UNSUPPORTED" or similar) with a clear
message and details including plugin_id and entry_id instead of returning
success, and likewise for getattr(host, "enable_entry")/getattr(host,
"disable_entry") — do not call asyncio.to_thread or return ActionExecuteResponse
when the method is None, raise the ServerDomainError so unsupported/no-op
operations are reported as failures.
In `@plugin/server/application/actions/list_actions_provider.py`:
- Around line 60-76: The navigation branch currently coerces a missing target
into an empty string and still returns an ActionDescriptor, which yields
clickable navigation entries with no destination; update the logic in the branch
that checks kind in _NAVIGATION_KINDS to first read target =
action.get("target") and if target is None or empty/whitespace, skip creating
the ActionDescriptor (e.g., return None or continue from the caller) instead of
converting it to ""—leave open_in handling and the rest of the fields
(full_action_id, label, description, plugin_name, plugin_id, action_id) intact
when a valid target exists so ActionDescriptor is only returned when target is a
non-empty URL/path.
In `@plugin/server/application/actions/preferences_service.py`:
- Around line 41-51: _save_sync currently swallows all exceptions and writes
non-atomically; change it to perform an atomic replace by writing the JSON to a
temporary file in the same directory and then atomically replace the target (use
os.replace or equivalent) so partial writes can't leave corrupted JSON, and
ensure parent dir exists before creating the temp file; remove the broad except
that hides failures (or re-raise the exception after logging) so errors
propagate up instead of being silently ignored. Also update
PreferencesService.save() to allow the exception from _save_sync to bubble (do
not catch and convert to a success return), so the HTTP route can return an
error to the client when saving fails. Ensure references: _save_sync,
PreferencesService.save() (and consider _load_sync when validating JSON on
load).
In `@plugin/server/application/actions/settings_provider.py`:
- Around line 66-72: The _resolve_annotation function fails to handle PEP 604
unions (X | None) because it only checks for typing.Union; update
_resolve_annotation to also treat types.UnionType (the result of X | Y) as a
union by checking get_origin(annotation) against both typing.Union and
types.UnionType and then unwrap NoneType via get_args to return the core type;
additionally, in the code that reads field annotations (around
settings_cls.model_fields usage) prefer using field_info.annotation instead of
re-indexing with settings_cls.model_fields[field_name].annotation to avoid an
extra dict lookup and ensure you pass the unwrapped core_type into the hot/type
checks so int|None and similar PEP 604 annotations are recognized.
In `@plugin/server/application/actions/system_provider.py`:
- Around line 42-54: The _has_static_ui function currently hardcodes
"index.html" when checking for a plugin UI; instead read the configured entry
from the static_ui_config (e.g., static_ui_obj.get("index_file")) defaulting to
"index.html", validate it's a non-empty str, then use that variable when
building the Path check (replace (p / "index.html").is_file() with (p /
index_file).is_file()). Update references in _has_static_ui and ensure behavior
matches register_static_ui(index_file=...).
In `@plugin/server/infrastructure/plugin_settings_resolver.py`:
- Around line 1-9: The module-level docstring claims this file centralises
settings-class resolution but two places still reimplement
importlib/getattr/issubclass logic; replace those duplicates by calling
resolve_settings_class(plugin_id, host=host) from this module. Specifically, in
hot_update_service.py::_validate_hot_fields and
plugin/server/routes/config.py::_import_settings_class remove the manual
importlib.import_module + getattr(..., "Settings") + issubclass(PluginSettings)
sequence and instead call resolve_settings_class(...) (passing host when
present) and use the returned class; ensure error handling remains equivalent
(raise same exceptions or map them) so behavior is unchanged.
In `@static/app-react-chat-window.js`:
- Around line 415-419: The failure path in fetchChatActions() sets
_actionsLoading = false but does not trigger a re-render, leaving the panel
stuck in loading state; update the .catch handler to call renderWindow() (the
same render method used on success) after setting _actionsLoading = false so the
UI updates, ensuring you reference _actionsLoading and renderWindow() in the
fetchChatActions error branch to clear the loading indicator and return
_cachedActions as before.
---
Outside diff comments:
In `@plugin/core/host.py`:
- Around line 435-475: The PluginSettings refresh failure is currently swallowed
in the plugin_instance _refresh_settings block so code proceeds with
ctx._effective_config updated while the plugin instance still has old settings;
modify the _refresh_settings exception handler in the block that calls
plugin_instance._refresh_settings so that on any exception you roll back
ctx._effective_config to old_config, log the rollback and either re-raise or
return/fail early (i.e., do not continue to trigger config_change lifecycle);
ensure the same unique symbols are used (plugin_instance, _refresh_settings,
ctx._effective_config, config_change_handler) so the lifecycle callback only
runs when settings refresh succeeds.
---
Duplicate comments:
In `@plugin/server/application/config/hot_update_service.py`:
- Around line 159-194: The _validate_hot_fields function currently reimplements
entry_point parsing and class import logic that duplicates
plugin_settings_resolver; replace that block by calling
resolve_settings_class(plugin_id, host=host) to obtain settings_cls, return if
None, then call get_hot_fields(settings_cls) and proceed — remove the manual
importlib split/getattr/issubclass checks and the related debug-return paths;
rely on resolve_settings_class to validate PluginSettings subclass and fall back
behavior.
In `@plugin/server/routes/config.py`:
- Around line 239-297: The function _resolve_plugin_settings_schema duplicates
entry_point parsing and validation done by resolve_settings_class in
plugin/server/infrastructure/plugin_settings_resolver.py; replace the local
import/parse logic by calling resolve_settings_class(entry_point) (which already
falls back to plugins snapshot and ensures a PluginSettings subclass), remove
the helper _import_settings_class and any importlib import, and consolidate the
repeated "all-null" response dictionaries into a single helper or a single
return path at the end of _resolve_plugin_settings_schema so future fields only
need one change; keep existing uses of state and host lookup intact and ensure
the returned keys ("plugin_id","settings_class","toml_section","schema") are
produced the same way when a settings class is found (use settings_cls.__name__,
settings_cls.model_config.get("toml_section","settings"),
settings_cls.model_json_schema()).
---
Nitpick comments:
In `@plugin/server/application/actions/settings_provider.py`:
- Around line 219-230: The except block that swallows all exceptions when
calling load_plugin_config (affecting current_section/toml_section) should log
the error instead of being silent: in the try/except around
load_plugin_config(pid, validate=False) (the block that populates
current_section) catch Exception as e and call the module logger (e.g.,
logger.debug or logger.exception) with contextual info including pid and
toml_section and the exception message/traceback so TOML parse/permission/schema
errors are recorded for debugging while preserving the fallback behavior.
In `@plugin/server/routes/actions.py`:
- Around line 82-88: The silent except in the execute path hides failures from
preferences_service.touch_recent(action_id); instead of "except Exception: pass"
catch the exception as e and log it (e.g., logger.warning/exception) with
context including action_id and that touch_recent failed so debugging is
possible while keeping the execute response non-failing; update the block around
touch_recent in execute to "except Exception as e: logger.exception(...)" (or
logger.warning with the exception) and do not re-raise.
In `@plugin/tests/unit/server/test_action_execution_service.py`:
- Around line 156-224: Extract the repeated setup/teardown that mutates
real_state.plugin_hosts into a pytest fixture named patch_plugin_hosts that
captures original = real_state.plugin_hosts, yields a setter function
_set(hosts) which assigns real_state.plugin_hosts = hosts, and restores original
after yield; then replace the try/finally blocks in tests
test_button_entry_triggers_via_host, test_button_entry_plugin_not_running,
test_toggle_entry_enable, and test_toggle_entry_disable by calling
patch_plugin_hosts({"demo": host}) (or patch_plugin_hosts({}) for the
not-running case) at the start of each test so each test uses the fixture to set
and automatically restore real_state.plugin_hosts and avoid cross-test
pollution.
- Around line 70-87: 当前测试通过 ActionExecutionService.__new__ 绕过 __init__
并手动注入私有属性(_lifecycle, _aggregation, _settings_handler, _system_handler,
_list_action_handler),这会把测试和实现细节耦合且易碎;请修改 _build_service 让它使用
ActionExecutionService 的标准构造器(传入 lifecycle/aggregation 等依赖)或者在
ActionExecutionService 中新增一个明确的工厂方法(例如类方法 for_testing(lifecycle,
aggregation))来构造并初始化所有内部 handler(包括现有的
_SettingsActionHandler、_SystemActionHandler、_ListActionHandler 及将来可能新增的如
_NavigationHandler),然后在测试中改为调用该构造器/工厂,从而避免直接操作私有属性并确保未来添加 handler 不会导致
AttributeError。
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: aaec5edd-cf43-4b50-b1a3-089ad6bc8733
⛔ Files ignored due to path filters (1)
static/icons/quick_actions_icon.pngis excluded by!**/*.png
📒 Files selected for processing (36)
.gitignorefrontend/react-neko-chat/src/App.tsxfrontend/react-neko-chat/src/CommandPalette.tsxfrontend/react-neko-chat/src/message-schema.tsfrontend/react-neko-chat/src/styles.cssfrontend/react-neko-chat/src/test/CommandPalette.test.tsxmain_routers/actions_proxy_router.pymain_server.pyplugin/core/host.pyplugin/sdk/plugin/__init__.pyplugin/sdk/plugin/base.pyplugin/sdk/plugin/settings.pyplugin/sdk/shared/core/decorators.pyplugin/server/application/actions/__init__.pyplugin/server/application/actions/aggregation_service.pyplugin/server/application/actions/builtin_provider.pyplugin/server/application/actions/execution_service.pyplugin/server/application/actions/list_actions_provider.pyplugin/server/application/actions/preferences_service.pyplugin/server/application/actions/settings_provider.pyplugin/server/application/actions/system_provider.pyplugin/server/application/config/hot_update_service.pyplugin/server/domain/action_models.pyplugin/server/domain/action_provider.pyplugin/server/http_app.pyplugin/server/infrastructure/plugin_settings_resolver.pyplugin/server/routes/__init__.pyplugin/server/routes/actions.pyplugin/server/routes/config.pyplugin/tests/integration/test_action_routes.pyplugin/tests/unit/server/test_action_aggregation_service.pyplugin/tests/unit/server/test_action_execution_service.pyplugin/tests/unit/server/test_action_list_actions_provider.pyplugin/tests/unit/server/test_action_models.pyplugin/tests/unit/server/test_action_system_provider.pystatic/app-react-chat-window.js
| <button | ||
| className={`composer-tool-btn${quickActionsPanelOpen ? ' is-active' : ''}`} | ||
| type="button" | ||
| aria-label={i18n('chat.quickActionsAriaLabel', '快捷操作')} | ||
| title={i18n('chat.quickActionsLabel', '快捷操作')} | ||
| onClick={() => { | ||
| const willOpen = !quickActionsPanelOpen; | ||
| setQuickActionsPanelOpen(willOpen); | ||
| if (willOpen && onQuickActionsRequest) onQuickActionsRequest(); | ||
| }} | ||
| > | ||
| <img src="/static/icons/quick_actions_icon.png" alt="" aria-hidden="true" /> | ||
| </button> |
There was a problem hiding this comment.
这个快捷操作切换按钮少了 aria-expanded / aria-pressed 喵。
隔壁 Line 1432-1433 的 emoji 按钮正儿八经地挂了 aria-controls + aria-expanded,同一个组件里新加的快捷操作按钮却啥都没有,读屏用户就不知道按一下到底发生了什么啦~哼哼,再补两个属性就完美啦喵:
<button
className={`composer-tool-btn${quickActionsPanelOpen ? ' is-active' : ''}`}
type="button"
aria-label={i18n('chat.quickActionsAriaLabel', '快捷操作')}
title={i18n('chat.quickActionsLabel', '快捷操作')}
+ aria-pressed={quickActionsPanelOpen}
+ aria-expanded={quickActionsPanelOpen}
onClick={() => {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <button | |
| className={`composer-tool-btn${quickActionsPanelOpen ? ' is-active' : ''}`} | |
| type="button" | |
| aria-label={i18n('chat.quickActionsAriaLabel', '快捷操作')} | |
| title={i18n('chat.quickActionsLabel', '快捷操作')} | |
| onClick={() => { | |
| const willOpen = !quickActionsPanelOpen; | |
| setQuickActionsPanelOpen(willOpen); | |
| if (willOpen && onQuickActionsRequest) onQuickActionsRequest(); | |
| }} | |
| > | |
| <img src="/static/icons/quick_actions_icon.png" alt="" aria-hidden="true" /> | |
| </button> | |
| <button | |
| className={`composer-tool-btn${quickActionsPanelOpen ? ' is-active' : ''}`} | |
| type="button" | |
| aria-label={i18n('chat.quickActionsAriaLabel', '快捷操作')} | |
| title={i18n('chat.quickActionsLabel', '快捷操作')} | |
| aria-pressed={quickActionsPanelOpen} | |
| aria-expanded={quickActionsPanelOpen} | |
| onClick={() => { | |
| const willOpen = !quickActionsPanelOpen; | |
| setQuickActionsPanelOpen(willOpen); | |
| if (willOpen && onQuickActionsRequest) onQuickActionsRequest(); | |
| }} | |
| > | |
| <img src="/static/icons/quick_actions_icon.png" alt="" aria-hidden="true" /> | |
| </button> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/react-neko-chat/src/App.tsx` around lines 1402 - 1414, The quick
actions toggle button is missing ARIA state so screen readers can't tell whether
it opens the panel; update the button (the element that uses
quickActionsPanelOpen, setQuickActionsPanelOpen and onQuickActionsRequest) to
include appropriate attributes: add aria-expanded={quickActionsPanelOpen} and
aria-pressed={quickActionsPanelOpen} and include aria-controls pointing to the
panel's id (match the panel id used elsewhere) so assistive tech knows what is
being toggled. Ensure these attributes update based on quickActionsPanelOpen and
keep existing aria-label/title usage.
| description: string; | ||
| category: string; | ||
| plugin_id: string; | ||
| control?: 'toggle' | 'button' | 'dropdown' | 'number' | 'slider' | 'plugin_lifecycle' | 'entry_toggle'; |
There was a problem hiding this comment.
让可执行行处理 plugin_lifecycle,并拦截禁用/加载态喵。
CommandItem.control 暴露了 plugin_lifecycle,但 handleRowClick 只对 button 调 onExec,生命周期项会显示成可点击却没有任何效果喵。这里也没有在 item.disabled 或 loading 时返回,按钮类动作可能在加载中被重复触发喵。
🐛 建议修复喵
const hasParams = (() => {
if (item.control !== 'button') return false;
const schema = item.input_schema as Record<string, unknown> | undefined;
const props = schema?.properties as Record<string, unknown> | undefined;
return props && Object.keys(props).length > 0;
})();
+ const isDisabled = Boolean(item.disabled || loading);
+ const isExecutableInstant =
+ item.type === 'instant' &&
+ (item.control === 'button' || item.control === 'plugin_lifecycle' || !item.control);
const handleRowClick = () => {
+ if (isDisabled) return;
if (hasInlineWidget) return;
if (item.type === 'chat_inject') {
onInject(item.inject_text ?? '');
return;
@@
- if (item.control === 'button') {
+ if (isExecutableInstant) {
if (hasParams) {
setParamFormOpen(open => !open);
} else {
onExec(item.action_id, null);
}
@@
- className={`cp-row ${hasInlineWidget ? '' : 'cp-row-clickable'}`}
+ className={`cp-row ${hasInlineWidget ? '' : 'cp-row-clickable'} ${isDisabled ? 'is-disabled' : ''}`}
onClick={handleRowClick}
role={hasInlineWidget ? undefined : 'button'}
- tabIndex={hasInlineWidget ? undefined : 0}
- onKeyDown={hasInlineWidget ? undefined : (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleRowClick(); } }}
+ aria-disabled={!hasInlineWidget && isDisabled ? true : undefined}
+ tabIndex={hasInlineWidget || isDisabled ? undefined : 0}
+ onKeyDown={hasInlineWidget || isDisabled ? undefined : (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleRowClick(); } }}Also applies to: 365-405
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/react-neko-chat/src/CommandPalette.tsx` at line 15, handleRowClick
currently only calls onExec for control === 'button' and allows lifecycle rows
to appear clickable but do nothing and can be double-triggered when loading;
update handleRowClick to treat control === 'plugin_lifecycle' the same as
'button' (invoke item.onExec or the shared onExec handler) and add an early
return when item.disabled || item.loading to block clicks; also ensure you guard
against double-execution by checking/setting a loading flag (item.loading or a
local in-flight guard) before calling onExec so plugin lifecycle actions aren’t
retriggered while loading.
| const commit = () => { | ||
| if (local !== committed.current) { | ||
| committed.current = local; | ||
| onExec(item.action_id, local); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="cp-slider-wrap" onClick={e => e.stopPropagation()}> | ||
| <input | ||
| type="range" | ||
| className="cp-slider" | ||
| min={item.min ?? 0} | ||
| max={item.max ?? 100} | ||
| step={item.step ?? 1} | ||
| value={local} | ||
| disabled={item.disabled || loading} | ||
| aria-label={item.label} | ||
| onChange={e => setLocal(Number(e.target.value))} | ||
| onMouseUp={commit} | ||
| onTouchEnd={commit} | ||
| /> |
There was a problem hiding this comment.
补齐滑块的键盘提交路径喵。
现在只有鼠标/触摸结束会调用 commit,键盘调整 range 时只更新本地 local,不会触发 onExec,键盘用户改了值但后端不会收到喵。
🐛 建议修复喵
- const commit = () => {
- if (local !== committed.current) {
- committed.current = local;
- onExec(item.action_id, local);
+ const commit = (next = local) => {
+ if (next !== committed.current) {
+ committed.current = next;
+ onExec(item.action_id, next);
}
};
@@
- onChange={e => setLocal(Number(e.target.value))}
- onMouseUp={commit}
- onTouchEnd={commit}
+ onChange={e => setLocal(Number(e.target.value))}
+ onPointerUp={() => commit()}
+ onBlur={() => commit()}
+ onKeyUp={e => {
+ if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(e.key)) {
+ commit();
+ }
+ }}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/react-neko-chat/src/CommandPalette.tsx` around lines 132 - 153, The
range input currently only calls commit onMouseUp/onTouchEnd so keyboard
adjustments never trigger onExec; update the input handlers to also call commit
on keyboard and focus finish events (add onKeyUp that calls commit when Enter or
Space is pressed and add onBlur to call commit when focus leaves). Use the
existing commit function and the same symbols (local, committed.current, onExec,
item.action_id) so keyboard changes will set committed.current and invoke onExec
just like mouse/touch interactions.
| const step = item.step ?? 1; | ||
| const inc = () => { const n = Math.min(local + step, item.max ?? Infinity); setLocal(n); commit(n); }; | ||
| const dec = () => { const n = Math.max(local - step, item.min ?? -Infinity); setLocal(n); commit(n); }; | ||
|
|
||
| return ( | ||
| <div className="cp-num-group" onClick={e => e.stopPropagation()}> | ||
| <button type="button" className="cp-num-btn" disabled={item.disabled || loading} onClick={dec} aria-label={`${item.label} −`}>−</button> | ||
| <input | ||
| type="number" | ||
| className="cp-num-input" | ||
| value={local} | ||
| min={item.min} | ||
| max={item.max} | ||
| step={item.step} | ||
| disabled={item.disabled || loading} | ||
| aria-label={item.label} | ||
| onChange={e => { const v = Number(e.target.value); setLocal(v); commit(v); }} | ||
| /> |
There was a problem hiding this comment.
对数字输入提交前做有限值与范围校验喵。
onChange 直接提交 Number(e.target.value),用户手输可以绕过 min/max,空字符串还会变成 0,可能把越界配置发给执行接口喵。
🛡️ 建议修复喵
const step = item.step ?? 1;
- const inc = () => { const n = Math.min(local + step, item.max ?? Infinity); setLocal(n); commit(n); };
- const dec = () => { const n = Math.max(local - step, item.min ?? -Infinity); setLocal(n); commit(n); };
+ const clamp = (v: number) => Math.min(Math.max(v, item.min ?? -Infinity), item.max ?? Infinity);
+ const inc = () => { const n = clamp(local + step); setLocal(n); commit(n); };
+ const dec = () => { const n = clamp(local - step); setLocal(n); commit(n); };
@@
- onChange={e => { const v = Number(e.target.value); setLocal(v); commit(v); }}
+ onChange={e => {
+ const raw = e.target.value.trim();
+ if (!raw) return;
+ const next = Number(raw);
+ if (!Number.isFinite(next)) return;
+ const v = clamp(next);
+ setLocal(v);
+ commit(v);
+ }}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const step = item.step ?? 1; | |
| const inc = () => { const n = Math.min(local + step, item.max ?? Infinity); setLocal(n); commit(n); }; | |
| const dec = () => { const n = Math.max(local - step, item.min ?? -Infinity); setLocal(n); commit(n); }; | |
| return ( | |
| <div className="cp-num-group" onClick={e => e.stopPropagation()}> | |
| <button type="button" className="cp-num-btn" disabled={item.disabled || loading} onClick={dec} aria-label={`${item.label} −`}>−</button> | |
| <input | |
| type="number" | |
| className="cp-num-input" | |
| value={local} | |
| min={item.min} | |
| max={item.max} | |
| step={item.step} | |
| disabled={item.disabled || loading} | |
| aria-label={item.label} | |
| onChange={e => { const v = Number(e.target.value); setLocal(v); commit(v); }} | |
| /> | |
| const step = item.step ?? 1; | |
| const clamp = (v: number) => Math.min(Math.max(v, item.min ?? -Infinity), item.max ?? Infinity); | |
| const inc = () => { const n = clamp(local + step); setLocal(n); commit(n); }; | |
| const dec = () => { const n = clamp(local - step); setLocal(n); commit(n); }; | |
| return ( | |
| <div className="cp-num-group" onClick={e => e.stopPropagation()}> | |
| <button type="button" className="cp-num-btn" disabled={item.disabled || loading} onClick={dec} aria-label={`${item.label} −`}>−</button> | |
| <input | |
| type="number" | |
| className="cp-num-input" | |
| value={local} | |
| min={item.min} | |
| max={item.max} | |
| step={item.step} | |
| disabled={item.disabled || loading} | |
| aria-label={item.label} | |
| onChange={e => { | |
| const raw = e.target.value.trim(); | |
| if (!raw) return; | |
| const next = Number(raw); | |
| if (!Number.isFinite(next)) return; | |
| const v = clamp(next); | |
| setLocal(v); | |
| commit(v); | |
| }} | |
| /> | |
| </div> | |
| ) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/react-neko-chat/src/CommandPalette.tsx` around lines 173 - 190, The
onChange handler currently calls commit(Number(e.target.value)) directly which
turns empty strings into 0 and allows out-of-range/NaN values to be submitted;
update the input handling in CommandPalette's numeric control so onChange first
reads the raw string, treats empty string as null/undefined (update local state
to the empty string without committing), parses to a number safely (guard
against NaN), then clamps the parsed value to the defined item.min/item.max (use
item.min ?? -Infinity and item.max ?? Infinity) and only then calls
commit(clampedValue) and setLocal(clampedValue); also ensure the inc and dec
helpers use the same clamp logic and respect item.step, item.min, item.max,
item.disabled, and loading when computing n before calling setLocal/commit.
1. slider 补齐 onKeyUp 键盘提交路径 2. number 输入 commit 前 clamp 到 min/max + NaN 防护 3. preferences 原子写入(tempfile + os.replace)+ 异常上抛 4. settings_provider 支持 PEP 604 X|None 语法(types.UnionType) 5. fetchChatActions 失败后补 renderWindow() 清除 loading 状态 6. window.open 加 noopener,noreferrer + 拦截 javascript: 协议 7. 快捷操作按钮补 aria-pressed/aria-expanded 8. handleRowClick 加 disabled/loading 守卫防止重复触发 9. _has_static_ui 读取 index_file 配置而非硬编码 index.html
|
#1 — window.open 缺 noopener,noreferrer(App.tsx L1158-1164) Fixed in ae667f9. Added noopener,noreferrer to window.open and added a javascript: protocol guard to prevent XSS via malicious plugin navigation targets. @coderabbitai #2 — 快捷操作按钮缺 aria-expanded/aria-pressed(App.tsx L1402-1414) Fixed in ae667f9. Added both aria-pressed={quickActionsPanelOpen} and aria-expanded={quickActionsPanelOpen} to match the emoji button's pattern. @coderabbitai #3 — plugin_lifecycle control 在 handleRowClick 中没有处理(CommandPalette.tsx) Fixed in ae667f9. Added if (item.disabled || loading) return guard at the top of handleRowClick to prevent duplicate triggers. Note: plugin_lifecycle is no longer produced by any backend provider — the refactored SystemActionProvider only emits button controls, and BuiltinActionsProvider handles start/stop/reload as plain buttons. The type is kept in CommandItem for backward compat but will never appear in practice. @coderabbitai #4 — Slider 缺少键盘提交路径(CommandPalette.tsx L132-153) Fixed in ae667f9. Added onKeyUp={commit} to the range input so keyboard arrow-key adjustments also trigger the backend update. @coderabbitai #5 — Number 输入缺少范围校验(CommandPalette.tsx L173-190) Fixed in ae667f9. The commit callback now clamps the value to [min, max] before sending to onExec, and falls back to the current server value if the input is NaN. @coderabbitai #6 — _save_sync 静默吞异常 + 非原子写入(preferences_service.py L41-51) Fixed in ae667f9. Rewrote _save_sync to use tempfile.mkstemp + os.replace for atomic writes. Exceptions now propagate — save() will return a 500 to the client on disk failure instead of silently losing data. touch_recent is still wrapped in try/except at the route level since it's non-critical. @coderabbitai #7 — PEP 604 X | None 未处理(settings_provider.py L66-72) Fixed in ae667f9. _resolve_annotation now checks for both typing.Union and types.UnionType (PEP 604), so int | None = SettingsField(hot=True) style annotations are correctly unwrapped. @coderabbitai #8 — _has_static_ui 硬编码 index.html(system_provider.py L42-54) Fixed in ae667f9. Now reads index_file from static_ui_config, defaulting to "index.html" if not set. Plugins using custom entry files (e.g. register_static_ui(..., index_file="app.html")) will be correctly detected. @coderabbitai #9 — resolve_settings_class 没有完全替换旧逻辑(plugin_settings_resolver.py) Acknowledged — this is a valid observation. hot_update_service and #10 — fetchChatActions 失败后没有 renderWindow()(app-react-chat-window.js L415-419) Fixed in ae667f9. Added renderWindow() in the .catch block so the loading spinner is cleared even when the fetch fails. @coderabbitai |
|
Tip For best results, initiate chat on the files or code changes.
快速确认一下各条修复喵:
关于 🧠 Learnings usedIf you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
plugin/server/application/actions/settings_provider.py (3)
239-243: 重复字典查找,直接用field_info.annotation就好喵~Line 239 已经通过
items()拿到field_info了,Line 243 又回头settings_cls.model_fields[field_name].annotation再查一次字典,多此一举呢,傲娇地提醒一下!♻️ 顺手简化
- annotation = settings_cls.model_fields[field_name].annotation + annotation = field_info.annotation🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@plugin/server/application/actions/settings_provider.py` around lines 239 - 243, In the loop inside settings_provider.py that iterates over settings_cls.model_fields.items() (using variables field_name and field_info), remove the redundant dictionary lookup settings_cls.model_fields[field_name].annotation and use the already-available field_info.annotation instead; update any other occurrences in that loop that re-access settings_cls.model_fields[field_name] to reference field_info to avoid duplicate dictionary lookups.
157-166: Enum 分支里current_value的三元套三元有点绕喵~Line 163 那一长串
str(current_value.value) if isinstance(...) else str(current_value) if current_value is not None else None挤在一行里真的不太好读喵,提出来写成几行会清爽很多呢~不是 bug 啦,就是读的时候眼花嘛!♻️ 抽一下更清爽
+ def _enum_value_to_str(v: Any) -> str | None: + if v is None: + return None + if isinstance(v, enum.Enum): + return str(v.value) + return str(v) + # --- Enum subclass → dropdown --- if isinstance(core_type, type) and issubclass(core_type, enum.Enum): options = [str(m.value) for m in core_type] return ActionDescriptor( **base, control="dropdown", - current_value=str(current_value.value) if isinstance(current_value, enum.Enum) else str(current_value) if current_value is not None else None, + current_value=_enum_value_to_str(current_value), options=options, icon="📋", )(helper 放模块级就行啦~)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@plugin/server/application/actions/settings_provider.py` around lines 157 - 166, The ternary expression computing current_value in the enum branch is hard to read; extract that logic into a small module-level helper (e.g., format_enum_current_value(value)) or expand into a clear multi-line conditional, then call it in the ActionDescriptor instantiation (referencing core_type, current_value and ActionDescriptor) so the branch becomes readable and returns None or the stringified value consistently.
112-141: 数值字段只有gt/lt(不含等号)时永远走不到 slider 分支喵~Line 119 要求
ge is not None and le is not None两个都得存在才会给 slider,插件作者写Field(gt=0, lt=100)这种排他边界的话,就算上下界齐全也只能 fallback 成number,前端就少一个滑条呢~本喵觉得既然拿到的是连续区间,无论ge/gt、le/lt,只要上下界都有就可以当 slider 用嘛:♻️ 建议兼容 gt/lt
- if ge is not None and le is not None: + low = ge if ge is not None else gt + high = le if le is not None else lt + if low is not None and high is not None: step: float = 1.0 if core_type is int else 0.1 return ActionDescriptor( **base, control="slider", current_value=current_value, - min=ge, - max=le, + min=low, + max=high, step=step, icon="🎚", )(如果团队倾向严格区分闭/开区间由前端来做,那当本喵没说哒~)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@plugin/server/application/actions/settings_provider.py` around lines 112 - 141, The slider branch currently only triggers when both ge and le are present, but should also accept open bounds provided via gt/lt; update the logic in the int/float handling (the block using core_type, ge, le, gt, lt and returning ActionDescriptor with control="slider") so that it treats a pair of min/max as present if (ge or gt) and (le or lt) are both non-None, and use ge if available else gt for min and le if available else lt for max; preserve the existing step calculation (1.0 for int, 0.1 for float) and current_value/icon, and fall back to the "number" control only when one or both bounds are missing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@plugin/server/application/actions/settings_provider.py`:
- Around line 225-236: The code swallows all exceptions when calling
load_plugin_config which hides TOML/IO/parse errors and causes current_section
to silently stay empty; change the except block to catch Exception as e and log
the error (including pid and toml_section) using the module's logger (e.g.,
process_logger or the settings provider's logger) at debug or error level and
include the exception details/stack (logger.exception or logger.debug with
repr(e)) so failures reading/parsing config are visible while keeping the
fallback behavior of current_section.
In `@static/app-react-chat-window.js`:
- Around line 456-467: The optimistic update in saveChatActionPreferences
updates _cachedPreferences and calls renderWindow before the POST, but on
failure it only logs and never rolls back or shows feedback; modify
saveChatActionPreferences to save the previousPreferences before assigning
_cachedPreferences, perform the fetch and check response.ok (treat non-ok as
failure), and on any failure revert _cachedPreferences back to
previousPreferences and call renderWindow to restore UI (optionally trigger a
user-visible toast/error indicator). Ensure you reference the same function/save
endpoint (saveChatActionPreferences and fetch('/chat/actions/preferences')) so
the rollback and res.ok handling are applied in that code path.
- Around line 423-454: The payload omits the value key when value is undefined
because JSON.stringify({ value: value }) drops undefined; update the body to
explicitly serialize undefined as null by using a null-coalescing conversion
before stringifying (change the JSON.stringify call in executeChatAction to
ensure value is value ?? null) so the backend always receives { value: null }
for absent parameters.
---
Nitpick comments:
In `@plugin/server/application/actions/settings_provider.py`:
- Around line 239-243: In the loop inside settings_provider.py that iterates
over settings_cls.model_fields.items() (using variables field_name and
field_info), remove the redundant dictionary lookup
settings_cls.model_fields[field_name].annotation and use the already-available
field_info.annotation instead; update any other occurrences in that loop that
re-access settings_cls.model_fields[field_name] to reference field_info to avoid
duplicate dictionary lookups.
- Around line 157-166: The ternary expression computing current_value in the
enum branch is hard to read; extract that logic into a small module-level helper
(e.g., format_enum_current_value(value)) or expand into a clear multi-line
conditional, then call it in the ActionDescriptor instantiation (referencing
core_type, current_value and ActionDescriptor) so the branch becomes readable
and returns None or the stringified value consistently.
- Around line 112-141: The slider branch currently only triggers when both ge
and le are present, but should also accept open bounds provided via gt/lt;
update the logic in the int/float handling (the block using core_type, ge, le,
gt, lt and returning ActionDescriptor with control="slider") so that it treats a
pair of min/max as present if (ge or gt) and (le or lt) are both non-None, and
use ge if available else gt for min and le if available else lt for max;
preserve the existing step calculation (1.0 for int, 0.1 for float) and
current_value/icon, and fall back to the "number" control only when one or both
bounds are missing.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 31834541-08f3-46a9-b9e9-e4b9c17acf56
📒 Files selected for processing (6)
frontend/react-neko-chat/src/App.tsxfrontend/react-neko-chat/src/CommandPalette.tsxplugin/server/application/actions/preferences_service.pyplugin/server/application/actions/settings_provider.pyplugin/server/application/actions/system_provider.pystatic/app-react-chat-window.js
✅ Files skipped from review due to trivial changes (2)
- plugin/server/application/actions/system_provider.py
- plugin/server/application/actions/preferences_service.py
🚧 Files skipped from review as they are similar to previous changes (1)
- frontend/react-neko-chat/src/App.tsx
1. host.py: _refresh_settings() 失败时回滚 ctx._effective_config 并返回错误, 避免 config 和 settings 对象不一致的半更新状态 2. hot_update_service.py: _validate_hot_fields 改用 resolve_settings_class, 删除重复的 importlib 解析逻辑 3. routes/config.py: _resolve_plugin_settings_schema 改用 resolve_settings_class, 删除 _import_settings_class 函数和 import importlib
settings_provider.py: - load_plugin_config 异常不再静默吞掉,改为 logger.debug 记录 pid/section/异常 - 移除冗余 settings_cls.model_fields[field_name] 查找,直接用 field_info - enum 分支嵌套三元表达式拆为多行 if/elif/else - slider 判断从仅 ge+le 扩展为 (ge or gt) + (le or lt),gt/lt 也能触发 slider app-react-chat-window.js: - saveChatActionPreferences: 保存失败时回滚 _cachedPreferences 并 renderWindow - executeChatAction: value 为 undefined 时显式序列化为 null
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
plugin/server/application/actions/settings_provider.py (1)
204-206: 多余的空 dict 初始化,顺手清掉就好喵~
hosts_snapshot: dict[str, Any] = {}紧接着就被dict(state.plugin_hosts)覆盖了,上面那行纯属多余呢。加个类型注解到赋值行就行喵!♻️ 建议的小调整
- hosts_snapshot: dict[str, Any] = {} with state.acquire_plugin_hosts_read_lock(): - hosts_snapshot = dict(state.plugin_hosts) + hosts_snapshot: dict[str, Any] = dict(state.plugin_hosts)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@plugin/server/application/actions/settings_provider.py` around lines 204 - 206, 删除多余的初始化:移除第一行 hosts_snapshot: dict[str, Any] = {} 并把类型注解合并到赋值行上,直接在 with state.acquire_plugin_hosts_read_lock(): 块中写 hosts_snapshot: dict[str, Any] = dict(state.plugin_hosts);保持对 state.acquire_plugin_hosts_read_lock() 和 state.plugin_hosts 的使用不变以确保线程安全快照。static/app-react-chat-window.js (2)
396-421: 并发fetchChatActions可能出现"旧响应覆盖新响应"的时序问题喵~现在每次调用都会无条件发起一个新的 GET 请求,没有任何 in-flight 去重或序号标记呢。想象一下:用户打开面板触发请求 A,与此同时后端发来
list-actions-update触发请求 B,如果 A 因为网络抖动比 B 晚返回,_cachedActions就会被更旧的数据覆盖,面板上最新的插件动作瞬间消失喵~ 当前路径触发都是用户驱动的,概率不高,但属实是个坑。可以用一个 promise 复用 + 代际序号的小守卫解决,比如:
🐾 参考思路喵
+ var _actionsFetchSeq = 0; + var _actionsInFlight = null; function fetchChatActions() { + if (_actionsInFlight) return _actionsInFlight; + var mySeq = ++_actionsFetchSeq; _actionsLoading = true; renderWindow(); - return fetch('/chat/actions', { + _actionsInFlight = fetch('/chat/actions', { method: 'GET', headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) .then(function (res) { if (!res.ok) throw new Error('fetchChatActions: HTTP ' + res.status); return res.json(); }) .then(function (data) { - _cachedActions = (data && data.actions) || []; - _cachedPreferences = (data && data.preferences) || { pinned: [], hidden: [], recent: [] }; - _actionsLoading = false; - renderWindow(); + if (mySeq === _actionsFetchSeq) { + _cachedActions = (data && data.actions) || []; + _cachedPreferences = (data && data.preferences) || { pinned: [], hidden: [], recent: [] }; + _actionsLoading = false; + renderWindow(); + } return _cachedActions; }) .catch(function (err) { console.warn('[ReactChatWindow] fetchChatActions failed:', err); - _actionsLoading = false; - renderWindow(); + if (mySeq === _actionsFetchSeq) { + _actionsLoading = false; + renderWindow(); + } return _cachedActions; - }); + }) + .finally(function () { _actionsInFlight = null; }); + return _actionsInFlight; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@static/app-react-chat-window.js` around lines 396 - 421, fetchChatActions currently issues every GET unconditionally so out-of-order responses can overwrite newer data; update fetchChatActions to (1) maintain an in-flight promise (e.g. _inflightFetchActionsPromise) and return it when set to dedupe concurrent calls, and (2) add a monotonic request epoch/nonce (e.g. _fetchActionsEpoch incremented before each fetch and captured as localEpoch) and only apply response results to _cachedActions/_cachedPreferences and clear _actionsLoading if localEpoch === _fetchActionsEpoch (or otherwise store latestEpochSeen) so stale responses are ignored; ensure the promise is cleared on completion/error and renderWindow() still runs when appropriate.
1820-1827: 桥事件不看 overlay 状态就发请求,窗口关着也会一直拉喵~
plugin-state-change/list-actions-update两个桥事件会无差别触发fetchChatActions(),但窗口隐藏时renderWindow()会 early-return,拉回来的数据只能躺在缓存里喵。openWindow()本身在打开时也会再拉一次(L1458),所以这段时间里的网络往返全部是白费流量,插件频繁上下线的场景下还可能连续炸一串请求呢~ 建议只在overlay可见时再触发,或者加个 debounce 喵。🐾 建议修复喵
- // Re-fetch quick actions when plugin state changes or list_actions update - window.addEventListener(EVENT_PREFIX + 'plugin-state-change', function () { - fetchChatActions(); - }); - window.addEventListener(EVENT_PREFIX + 'list-actions-update', function () { - fetchChatActions(); - }); + // Re-fetch quick actions when plugin state changes or list_actions update. + // Only when the panel is visible — openWindow() already refreshes on open. + var refetchIfVisible = function () { + var overlay = getOverlay(); + if (overlay && !overlay.hidden) fetchChatActions(); + }; + window.addEventListener(EVENT_PREFIX + 'plugin-state-change', refetchIfVisible); + window.addEventListener(EVENT_PREFIX + 'list-actions-update', refetchIfVisible);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@static/app-react-chat-window.js` around lines 1820 - 1827, 事件处理器直接在 EVENT_PREFIX + 'plugin-state-change' 和 EVENT_PREFIX + 'list-actions-update' 上无条件调用 fetchChatActions() 导致窗口隐藏时也持续发请求;在这两个 listener 的回调里先检查 overlay 是否可见(与 renderWindow() 使用的同一可见性判定/状态),只有在可见时才调用 fetchChatActions(),或者将调用封装成一个带短时限 debounce 的函数(例如 createDebouncedFetch 或类似包装器)以合并高频事件;保留 openWindow() 中的按需拉取逻辑(L1458)不变,确保引用 fetchChatActions、renderWindow、openWindow 和事件名称以便定位修改点。
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@static/app-react-chat-window.js`:
- Around line 430-438: The fetch response handler that builds the thrown Error
currently uses body.detail directly (the anonymous .then callback handling
res.json()), causing non-string details (arrays/objects from FastAPI 422) to
become “[object Object]”; update the logic where `detail` is assigned so if
`body.detail` is not a string you convert it to a readable string (e.g.,
JSON.stringify(body.detail) or String(body.detail)) before throwing the Error,
ensuring the thrown Error contains a useful message for CommandPalette/toast and
keeping the existing fallback to `'HTTP ' + res.status` when body/detail is
absent.
---
Nitpick comments:
In `@plugin/server/application/actions/settings_provider.py`:
- Around line 204-206: 删除多余的初始化:移除第一行 hosts_snapshot: dict[str, Any] = {}
并把类型注解合并到赋值行上,直接在 with state.acquire_plugin_hosts_read_lock(): 块中写
hosts_snapshot: dict[str, Any] = dict(state.plugin_hosts);保持对
state.acquire_plugin_hosts_read_lock() 和 state.plugin_hosts 的使用不变以确保线程安全快照。
In `@static/app-react-chat-window.js`:
- Around line 396-421: fetchChatActions currently issues every GET
unconditionally so out-of-order responses can overwrite newer data; update
fetchChatActions to (1) maintain an in-flight promise (e.g.
_inflightFetchActionsPromise) and return it when set to dedupe concurrent calls,
and (2) add a monotonic request epoch/nonce (e.g. _fetchActionsEpoch incremented
before each fetch and captured as localEpoch) and only apply response results to
_cachedActions/_cachedPreferences and clear _actionsLoading if localEpoch ===
_fetchActionsEpoch (or otherwise store latestEpochSeen) so stale responses are
ignored; ensure the promise is cleared on completion/error and renderWindow()
still runs when appropriate.
- Around line 1820-1827: 事件处理器直接在 EVENT_PREFIX + 'plugin-state-change' 和
EVENT_PREFIX + 'list-actions-update' 上无条件调用 fetchChatActions()
导致窗口隐藏时也持续发请求;在这两个 listener 的回调里先检查 overlay 是否可见(与 renderWindow()
使用的同一可见性判定/状态),只有在可见时才调用 fetchChatActions(),或者将调用封装成一个带短时限 debounce 的函数(例如
createDebouncedFetch 或类似包装器)以合并高频事件;保留 openWindow() 中的按需拉取逻辑(L1458)不变,确保引用
fetchChatActions、renderWindow、openWindow 和事件名称以便定位修改点。
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 8f0923f1-cd6d-4682-8ac0-b09cc8fe2351
📒 Files selected for processing (2)
plugin/server/application/actions/settings_provider.pystatic/app-react-chat-window.js
- executeChatAction: body.detail 非 string 时 JSON.stringify 避免 [object Object] - settings_provider: 移除多余的 hosts_snapshot 空 dict 初始化 - 事件监听: plugin-state-change/list-actions-update 仅在窗口可见时 fetchChatActions
1. / 斜杠命令模式:
- 输入框空白时输入 / 自动打开命令面板并预填 /
- / 前缀搜索只显示 chat_inject 类型操作
- 按钮打开面板时不带 / 前缀(正常模式)
2. 键盘导航:
- ↑↓ 在行之间移动高亮
- Enter 执行高亮行(inject/navigate/button)
- 搜索内容变化时重置高亮
- cp-row-highlighted CSS + 暗色模式
3. chat_command entry → chat_inject:
- SystemActionProvider 识别 kind=chat_command 的 entry
- 自动生成 chat_inject 类型操作,inject_text 为 @{plugin} /{entry_id}
- 用户在 / 模式下可直接看到并使用
将 initialSearch='/' 改为 slashMode boolean prop。 面板在 slash mode 下只显示 chat_inject 类型操作, 搜索框保持空白,placeholder 改为「搜索斜杠命令...」。 用户可以在空搜索框里继续输入关键词过滤命令。
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (3)
frontend/react-neko-chat/src/CommandPalette.tsx (2)
500-501:⚠️ Potential issue | 🟡 MinorToast 缺少可访问性播报属性喵。
Line 500到Line 501的 toast 目前是普通div,失败/成功结果不一定会被读屏及时播报喵。建议补role与aria-live喵。🐾 建议修复(增加 live region)喵
- <div key={t.id} className={`message-block-status tone-${t.tone} cp-toast`}> + <div + key={t.id} + className={`message-block-status tone-${t.tone} cp-toast`} + role={t.tone === 'error' ? 'alert' : 'status'} + aria-live={t.tone === 'error' ? 'assertive' : 'polite'} + aria-atomic="true" + > {t.text} </div>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/react-neko-chat/src/CommandPalette.tsx` around lines 500 - 501, The toast markup using the div with class "message-block-status tone-${t.tone} cp-toast" isn't accessible to screen readers; update that element to include ARIA live region attributes (e.g., add role="status" or role="alert" depending on severity, and aria-live="polite" or "assertive" as appropriate, plus aria-atomic="true") so success/failure messages are announced reliably; locate the JSX rendering the div with className={`message-block-status tone-${t.tone} cp-toast`} in CommandPalette.tsx and add the appropriate role/aria-live/aria-atomic attributes consistent with the tone semantics.
195-195:⚠️ Potential issue | 🟠 Major数字输入清空时会被误提交为 0 喵。
Line 195的Number(e.target.value)会把空字符串转成0,用户编辑过程里会触发意外提交喵。这里建议先按原始字符串判断空值/非法值,再决定是否提交喵。🐾 建议修复(先校验 raw,再 commit)喵
- onChange={e => { const v = Number(e.target.value); setLocal(v); commit(v); }} + onChange={e => { + const raw = e.target.value.trim(); + if (!raw) return; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return; + const lo = item.min ?? -Infinity; + const hi = item.max ?? Infinity; + const v = Math.min(Math.max(parsed, lo), hi); + setLocal(v); + commit(v); + }}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/react-neko-chat/src/CommandPalette.tsx` at line 195, The onChange handler currently uses Number(e.target.value) which converts empty string to 0 and causes accidental commits; update the handler in CommandPalette's onChange (where setLocal and commit are called) to first inspect the raw string (e.target.value): if it's empty or not a valid numeric string, update local state to reflect the empty/invalid input but do NOT call commit; otherwise parse the string to a number and then call commit(parsedNumber). Ensure you still call setLocal with the raw/appropriate representation so the input shows the user's edit.frontend/react-neko-chat/src/App.tsx (1)
1140-1147:⚠️ Potential issue | 🟠 Major跳转目标校验还不够严格喵。
Line 1142现在只拦了javascript:,但data:、file:等非http(s)协议仍可跳转,安全边界还不完整喵。建议改成“仅允许http:/https:/ 站内相对路径”的白名单校验后再导航喵。🐾 建议修复(协议白名单)喵
+ const normalizeQuickActionTarget = (raw: string): string | null => { + const target = String(raw || '').trim(); + if (!target) return null; + if (target.startsWith('/')) return target; + try { + const parsed = new URL(target, window.location.origin); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return parsed.toString(); + } + } catch { + return null; + } + return null; + }; + const handleQuickActionNavigate = useCallback((target: string, openIn: string) => { - // Only allow http(s) and relative URLs — block javascript: and other dangerous protocols - if (target && /^javascript:/i.test(target)) return; + const safeTarget = normalizeQuickActionTarget(target); + if (!safeTarget) return; if (openIn === 'same_tab') { - window.location.href = target; + window.location.href = safeTarget; } else { - window.open(target, '_blank', 'noopener,noreferrer'); + const w = window.open(safeTarget, '_blank', 'noopener,noreferrer'); + if (w) w.opener = null; } }, []);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/react-neko-chat/src/App.tsx` around lines 1140 - 1147, handleQuickActionNavigate currently only blocks "javascript:" but allows other dangerous protocols; change the validation to a strict whitelist: allow relative paths (e.g., starting with "/" or "./" or "../") or absolute URLs whose protocol is exactly "http:" or "https:"; reject anything else (including data:, file:, ftp:, mailto:, etc.) before performing navigation. Update the check inside handleQuickActionNavigate(target, openIn) to parse/validate the target (e.g., via URL constructor for absolute URLs and string checks for relative paths), return early on invalid targets, and then proceed with window.location.href or window.open('_blank', 'noopener,noreferrer') as currently implemented.
🧹 Nitpick comments (1)
plugin/server/application/actions/settings_provider.py (1)
66-78: PEP 604 修复收到喵~ 现在int | None这种现代写法也能正确拆包了呢,本喵满意!不过顺手提一个小小的风格统一建议:既然已经拿到了
origin,其实直接origin is _types.UnionType就够了,和上面的origin is typing.Union形成对称,读起来更舒服喵:♻️ 可选的小整理
- origin = get_origin(annotation) - if origin is typing.Union or isinstance(annotation, _types.UnionType): + origin = get_origin(annotation) + if origin is typing.Union or origin is _types.UnionType: args = [a for a in get_args(annotation) if a is not type(None)] return args[0] if args else None两种写法效果等价(PEP 604 的
X | Y的get_origin就是types.UnionType),纯粹审美偏好而已,不改也完全 OK 喵~🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@plugin/server/application/actions/settings_provider.py` around lines 66 - 78, The function _resolve_annotation currently checks PEP 604 unions using isinstance(annotation, _types.UnionType); instead, use the already-computed origin and test origin is _types.UnionType to mirror the origin is typing.Union check for symmetry and clarity—update the condition in _resolve_annotation to check "origin is typing.Union or origin is _types.UnionType" (keeping imports and the rest of the logic unchanged) so get_origin-driven branches are consistent and more readable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@plugin/server/application/actions/settings_provider.py`:
- Around line 249-265: The loop over settings_cls.model_fields is propagating
Pydantic's PydanticUndefined sentinel into current_value via field_info.default;
change the logic that sets current_value in the block around
_build_descriptor_for_field so that if field_info.default is the
PydanticUndefined sentinel you treat it as None (i.e., compute current_value
from current_section.get(field_name, None) or normalize field_info.default ->
None when it equals PydanticUndefined) before calling
_build_descriptor_for_field so downstream checks (e.g., in
toggle/slider/dropdown branches) see None instead of the sentinel.
In `@static/app-react-chat-window.js`:
- Around line 396-420: fetchChatActions() can run concurrently and older
responses may overwrite newer state; fix by introducing a request sequence or
token (e.g., _actionsFetchSeq) that you increment at the start of
fetchChatActions(), capture into a local variable before calling fetch(), and
only apply the response (update _cachedActions, _cachedPreferences,
_actionsLoading and call renderWindow()) if the captured sequence equals the
current _actionsFetchSeq; alternatively create and store an AbortController per
call and abort the previous controller when starting a new fetch. Reference
symbols: fetchChatActions, _actionsLoading, _cachedActions, _cachedPreferences,
renderWindow.
---
Duplicate comments:
In `@frontend/react-neko-chat/src/App.tsx`:
- Around line 1140-1147: handleQuickActionNavigate currently only blocks
"javascript:" but allows other dangerous protocols; change the validation to a
strict whitelist: allow relative paths (e.g., starting with "/" or "./" or
"../") or absolute URLs whose protocol is exactly "http:" or "https:"; reject
anything else (including data:, file:, ftp:, mailto:, etc.) before performing
navigation. Update the check inside handleQuickActionNavigate(target, openIn) to
parse/validate the target (e.g., via URL constructor for absolute URLs and
string checks for relative paths), return early on invalid targets, and then
proceed with window.location.href or window.open('_blank',
'noopener,noreferrer') as currently implemented.
In `@frontend/react-neko-chat/src/CommandPalette.tsx`:
- Around line 500-501: The toast markup using the div with class
"message-block-status tone-${t.tone} cp-toast" isn't accessible to screen
readers; update that element to include ARIA live region attributes (e.g., add
role="status" or role="alert" depending on severity, and aria-live="polite" or
"assertive" as appropriate, plus aria-atomic="true") so success/failure messages
are announced reliably; locate the JSX rendering the div with
className={`message-block-status tone-${t.tone} cp-toast`} in CommandPalette.tsx
and add the appropriate role/aria-live/aria-atomic attributes consistent with
the tone semantics.
- Line 195: The onChange handler currently uses Number(e.target.value) which
converts empty string to 0 and causes accidental commits; update the handler in
CommandPalette's onChange (where setLocal and commit are called) to first
inspect the raw string (e.target.value): if it's empty or not a valid numeric
string, update local state to reflect the empty/invalid input but do NOT call
commit; otherwise parse the string to a number and then call
commit(parsedNumber). Ensure you still call setLocal with the raw/appropriate
representation so the input shows the user's edit.
---
Nitpick comments:
In `@plugin/server/application/actions/settings_provider.py`:
- Around line 66-78: The function _resolve_annotation currently checks PEP 604
unions using isinstance(annotation, _types.UnionType); instead, use the
already-computed origin and test origin is _types.UnionType to mirror the origin
is typing.Union check for symmetry and clarity—update the condition in
_resolve_annotation to check "origin is typing.Union or origin is
_types.UnionType" (keeping imports and the rest of the logic unchanged) so
get_origin-driven branches are consistent and more readable.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 099c56cd-0cda-48fc-871b-a0f603d1c72a
📒 Files selected for processing (6)
frontend/react-neko-chat/src/App.tsxfrontend/react-neko-chat/src/CommandPalette.tsxfrontend/react-neko-chat/src/styles.cssplugin/server/application/actions/settings_provider.pyplugin/server/application/actions/system_provider.pystatic/app-react-chat-window.js
✅ Files skipped from review due to trivial changes (1)
- frontend/react-neko-chat/src/styles.css
🚧 Files skipped from review as they are similar to previous changes (1)
- plugin/server/application/actions/system_provider.py
| for field_name, field_info in settings_cls.model_fields.items(): | ||
| if not _is_hot(field_info): | ||
| continue | ||
|
|
||
| annotation = field_info.annotation | ||
| current_value = current_section.get(field_name, field_info.default) | ||
|
|
||
| descriptor = _build_descriptor_for_field( | ||
| plugin_id=pid, | ||
| plugin_name=plugin_name, | ||
| field_name=field_name, | ||
| field_info=field_info, | ||
| annotation=annotation, | ||
| current_value=current_value, | ||
| ) | ||
| if descriptor is not None: | ||
| actions.append(descriptor) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# 确认仓库内是否真的有 hot=True 但无 default 的字段(评估触发概率)
rg -nP --type=py -C2 'SettingsField\s*\([^)]*hot\s*=\s*True'Repository: Project-N-E-K-O/N.E.K.O
Length of output: 464
🏁 Script executed:
# 1. 首先查看 settings_provider.py 的具体代码(249-265行)
head -265 plugin/server/application/actions/settings_provider.py | tail -30 | cat -nRepository: Project-N-E-K-O/N.E.K.O
Length of output: 1412
🏁 Script executed:
# 2. 搜索所有带 hot=True 的 SettingsField,检查是否有不带 default 的
rg -nP --type=py 'SettingsField\s*\([^)]*hot\s*=\s*True' -A2 -B2Repository: Project-N-E-K-O/N.E.K.O
Length of output: 464
🏁 Script executed:
# 3. 查看 _build_descriptor_for_field 函数的实现
rg -n '_build_descriptor_for_field' plugin/server/application/actions/settings_provider.pyRepository: Project-N-E-K-O/N.E.K.O
Length of output: 160
🏁 Script executed:
# 4. 检查 Pydantic 的导入和使用,确认 PydanticUndefined 的处理
rg -n 'PydanticUndefined|from pydantic' plugin/server/application/actions/settings_provider.pyRepository: Project-N-E-K-O/N.E.K.O
Length of output: 49
🏁 Script executed:
# 读取 _build_descriptor_for_field 函数的完整实现
sed -n '81,200p' plugin/server/application/actions/settings_provider.py | cat -nRepository: Project-N-E-K-O/N.E.K.O
Length of output: 4791
🏁 Script executed:
# 扩大范围搜索所有的 SettingsField 定义,包括没有 default 的
rg -nP --type=py 'SettingsField\s*\(' -A1 | head -100Repository: Project-N-E-K-O/N.E.K.O
Length of output: 460
🏁 Script executed:
# 查看 current_section.get 的调用情况和之后的使用
sed -n '240,280p' plugin/server/application/actions/settings_provider.py | cat -nRepository: Project-N-E-K-O/N.E.K.O
Length of output: 1742
🏁 Script executed:
# 检查是否使用了 Pydantic v2,查看版本信息
rg -n 'pydantic' plugin/pyproject.toml plugin/setup.py plugin/requirements.txt 2>/dev/null | head -20Repository: Project-N-E-K-O/N.E.K.O
Length of output: 49
🏁 Script executed:
# 更广泛地搜索所有 SettingsField,找出没有 default 参数的
rg -nP --type=py 'SettingsField\s*\(' -B1 -A3 | grep -A3 'SettingsField'Repository: Project-N-E-K-O/N.E.K.O
Length of output: 654
🏁 Script executed:
# 搜索所有的 PluginSettings 子类定义
rg -n 'class.*PluginSettings' --type=py -A20 | head -150Repository: Project-N-E-K-O/N.E.K.O
Length of output: 5674
🏁 Script executed:
# 检查 PydanticUndefined 是否在其他地方被处理过
rg -n 'PydanticUndefined' --type=pyRepository: Project-N-E-K-O/N.E.K.O
Length of output: 504
🏁 Script executed:
# 查看 plugin/sdk/plugin/settings.py 中对 PydanticUndefined 的处理
sed -n '120,160p' plugin/sdk/plugin/settings.py | cat -nRepository: Project-N-E-K-O/N.E.K.O
Length of output: 1967
🏁 Script executed:
# 检查是否有其他地方对 current_value 做了 undefined 检查
rg -n 'current_value' plugin/server/application/actions/settings_provider.py -B2 -A2Repository: Project-N-E-K-O/N.E.K.O
Length of output: 2200
🏁 Script executed:
# 验证 bool(PydanticUndefined) 的行为
python3 << 'EOF'
from pydantic_core import PydanticUndefined
print(f"bool(PydanticUndefined) = {bool(PydanticUndefined)}")
print(f"str(PydanticUndefined) = {str(PydanticUndefined)}")
print(f"PydanticUndefined is not None = {PydanticUndefined is not None}")
EOFRepository: Project-N-E-K-O/N.E.K.O
Length of output: 177
L254 的 field_info.default 会把 PydanticUndefined 哨兵偷偷塞进 current_value 喵!
当 hot=True 字段没有显式 default(例如 foo: int = SettingsField(hot=True, ge=0, le=100)),Pydantic v2 的 field_info.default 就会等于 PydanticUndefined 这个哨兵对象。由于 bool(PydanticUndefined) == True 且 PydanticUndefined is not None == True,这个哨兵会一路流向各个 descriptor 分支:
- toggle 分支 (L108):
current_value is not None为 True,bool(PydanticUndefined)返回 True,面板错误显示"已开启"喵 - slider / number 分支 (L128, L139):哨兵被序列化成
"PydanticUndefined"字符串喵 - dropdown 分支 (L152, L171, L184):同样被
str()转成诡异字符串喵
建议在 L254 过滤掉哨兵,没有值就用 None:
🛡️ 建议过滤掉哨兵
+from pydantic_core import PydanticUndefined
+
...
- current_value = current_section.get(field_name, field_info.default)
+ raw_default = field_info.default
+ if raw_default is PydanticUndefined:
+ raw_default = None
+ current_value = current_section.get(field_name, raw_default)这样 _build_descriptor_for_field 里现有的 if current_value is not None 判断就能正常兜底啦喵~
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@plugin/server/application/actions/settings_provider.py` around lines 249 -
265, The loop over settings_cls.model_fields is propagating Pydantic's
PydanticUndefined sentinel into current_value via field_info.default; change the
logic that sets current_value in the block around _build_descriptor_for_field so
that if field_info.default is the PydanticUndefined sentinel you treat it as
None (i.e., compute current_value from current_section.get(field_name, None) or
normalize field_info.default -> None when it equals PydanticUndefined) before
calling _build_descriptor_for_field so downstream checks (e.g., in
toggle/slider/dropdown branches) see None instead of the sentinel.
| function fetchChatActions() { | ||
| _actionsLoading = true; | ||
| renderWindow(); | ||
| return fetch('/chat/actions', { | ||
| method: 'GET', | ||
| headers: { 'Accept': 'application/json' }, | ||
| credentials: 'same-origin' | ||
| }) | ||
| .then(function (res) { | ||
| if (!res.ok) throw new Error('fetchChatActions: HTTP ' + res.status); | ||
| return res.json(); | ||
| }) | ||
| .then(function (data) { | ||
| _cachedActions = (data && data.actions) || []; | ||
| _cachedPreferences = (data && data.preferences) || { pinned: [], hidden: [], recent: [] }; | ||
| _actionsLoading = false; | ||
| renderWindow(); | ||
| return _cachedActions; | ||
| }) | ||
| .catch(function (err) { | ||
| console.warn('[ReactChatWindow] fetchChatActions failed:', err); | ||
| _actionsLoading = false; | ||
| renderWindow(); | ||
| return _cachedActions; | ||
| }); |
There was a problem hiding this comment.
避免并发刷新把新状态被旧响应回写覆盖喵。
Line 396 到 Line 420 的 fetchChatActions() 可能并发触发,多次请求乱序返回时,较旧响应会覆盖较新缓存,导致面板短时间显示过期按钮状态喵。建议加请求序号(或 AbortController)只应用“最后一次请求”的结果喵。
🐾 建议修复(请求序号防抖回写)喵
var _actionsLoading = false;
+var _actionsFetchSeq = 0;
function fetchChatActions() {
+ var requestSeq = ++_actionsFetchSeq;
_actionsLoading = true;
renderWindow();
return fetch('/chat/actions', {
@@
.then(function (data) {
+ if (requestSeq !== _actionsFetchSeq) {
+ return _cachedActions;
+ }
_cachedActions = (data && data.actions) || [];
_cachedPreferences = (data && data.preferences) || { pinned: [], hidden: [], recent: [] };
_actionsLoading = false;
renderWindow();
return _cachedActions;
})
.catch(function (err) {
+ if (requestSeq !== _actionsFetchSeq) {
+ return _cachedActions;
+ }
console.warn('[ReactChatWindow] fetchChatActions failed:', err);
_actionsLoading = false;
renderWindow();
return _cachedActions;
});
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function fetchChatActions() { | |
| _actionsLoading = true; | |
| renderWindow(); | |
| return fetch('/chat/actions', { | |
| method: 'GET', | |
| headers: { 'Accept': 'application/json' }, | |
| credentials: 'same-origin' | |
| }) | |
| .then(function (res) { | |
| if (!res.ok) throw new Error('fetchChatActions: HTTP ' + res.status); | |
| return res.json(); | |
| }) | |
| .then(function (data) { | |
| _cachedActions = (data && data.actions) || []; | |
| _cachedPreferences = (data && data.preferences) || { pinned: [], hidden: [], recent: [] }; | |
| _actionsLoading = false; | |
| renderWindow(); | |
| return _cachedActions; | |
| }) | |
| .catch(function (err) { | |
| console.warn('[ReactChatWindow] fetchChatActions failed:', err); | |
| _actionsLoading = false; | |
| renderWindow(); | |
| return _cachedActions; | |
| }); | |
| var _actionsLoading = false; | |
| var _actionsFetchSeq = 0; | |
| function fetchChatActions() { | |
| var requestSeq = ++_actionsFetchSeq; | |
| _actionsLoading = true; | |
| renderWindow(); | |
| return fetch('/chat/actions', { | |
| method: 'GET', | |
| headers: { 'Accept': 'application/json' }, | |
| credentials: 'same-origin' | |
| }) | |
| .then(function (res) { | |
| if (!res.ok) throw new Error('fetchChatActions: HTTP ' + res.status); | |
| return res.json(); | |
| }) | |
| .then(function (data) { | |
| if (requestSeq !== _actionsFetchSeq) { | |
| return _cachedActions; | |
| } | |
| _cachedActions = (data && data.actions) || []; | |
| _cachedPreferences = (data && data.preferences) || { pinned: [], hidden: [], recent: [] }; | |
| _actionsLoading = false; | |
| renderWindow(); | |
| return _cachedActions; | |
| }) | |
| .catch(function (err) { | |
| if (requestSeq !== _actionsFetchSeq) { | |
| return _cachedActions; | |
| } | |
| console.warn('[ReactChatWindow] fetchChatActions failed:', err); | |
| _actionsLoading = false; | |
| renderWindow(); | |
| return _cachedActions; | |
| }); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@static/app-react-chat-window.js` around lines 396 - 420, fetchChatActions()
can run concurrently and older responses may overwrite newer state; fix by
introducing a request sequence or token (e.g., _actionsFetchSeq) that you
increment at the start of fetchChatActions(), capture into a local variable
before calling fetch(), and only apply the response (update _cachedActions,
_cachedPreferences, _actionsLoading and call renderWindow()) if the captured
sequence equals the current _actionsFetchSeq; alternatively create and store an
AbortController per call and abort the previous controller when starting a new
fetch. Reference symbols: fetchChatActions, _actionsLoading, _cachedActions,
_cachedPreferences, renderWindow.
Summary by CodeRabbit