From a10868af837170b588649689dfc9a11d3e6a5d08 Mon Sep 17 00:00:00 2001 From: LocalBuilder Date: Tue, 12 May 2026 19:48:45 +0800 Subject: [PATCH 01/24] feat(sdk): add PluginSettings base class + @quick_action decorator - Add PluginSettings(BaseModel) and SettingsField(hot=True) for typed plugin configuration with hot-update field markers - Add @quick_action decorator that works both above and below @plugin_entry - Add 'chat_command' to EntryKind literal type - Export PluginSettings, SettingsField, quick_action from plugin SDK surface - Refactor llm_tool import to avoid reload shadowing --- plugin/sdk/plugin/__init__.py | 15 ++- plugin/sdk/plugin/settings.py | 192 +++++++++++++++++++++++++++ plugin/sdk/shared/core/decorators.py | 46 ++++++- 3 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 plugin/sdk/plugin/settings.py diff --git a/plugin/sdk/plugin/__init__.py b/plugin/sdk/plugin/__init__.py index 8e37903bd9..abecad31d0 100644 --- a/plugin/sdk/plugin/__init__.py +++ b/plugin/sdk/plugin/__init__.py @@ -5,13 +5,17 @@ from __future__ import annotations +import importlib + from . import base as _base from . import decorators as _decorators -from . import llm_tool as _llm_tool from . import runtime as _runtime +from . import settings as _settings from . import ui as ui from plugin.sdk.shared.i18n import PluginI18n, tr +_llm_tool = importlib.import_module(f"{__name__}.llm_tool") + # --- Base --- NEKO_PLUGIN_META_ATTR = _base.NEKO_PLUGIN_META_ATTR NEKO_PLUGIN_TAG = _base.NEKO_PLUGIN_TAG @@ -33,6 +37,7 @@ around_entry = _decorators.around_entry replace_entry = _decorators.replace_entry plugin = _decorators.plugin +quick_action = _decorators.quick_action # --- LLM tool --- llm_tool = _llm_tool.llm_tool @@ -60,6 +65,10 @@ # --- Logging --- get_plugin_logger = _runtime.get_plugin_logger +# --- Settings --- +PluginSettings = _settings.PluginSettings +SettingsField = _settings.SettingsField + __all__ = [ # Base "NEKO_PLUGIN_META_ATTR", @@ -81,6 +90,7 @@ "around_entry", "replace_entry", "plugin", + "quick_action", "ui", # LLM tool "llm_tool", @@ -105,4 +115,7 @@ "TransportError", # Logging "get_plugin_logger", + # Settings + "PluginSettings", + "SettingsField", ] diff --git a/plugin/sdk/plugin/settings.py b/plugin/sdk/plugin/settings.py new file mode 100644 index 0000000000..9510a6ef2d --- /dev/null +++ b/plugin/sdk/plugin/settings.py @@ -0,0 +1,192 @@ +"""PluginSettings base class and helpers for typed plugin configuration. + +Provides a Pydantic v2 BaseModel subclass that plugin developers inherit +to declare their business configuration schema with type validation, +defaults, and hot-update field markers. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic.fields import FieldInfo +from pydantic_core import PydanticUndefined + +from plugin.logging_config import get_logger + +logger = get_logger("sdk.plugin.settings") + + +class PluginSettings(BaseModel): + """插件业务配置基类。 + + 插件开发者继承此类声明配置字段,框架自动从 effective_config 中 + 提取对应 toml_section 的数据并创建实例。 + + Example:: + + class MySettings(PluginSettings): + model_config = ConfigDict(toml_section="settings") + + timeout: int = SettingsField(default=30, hot=True, description="请求超时(秒)") + api_key: str = SettingsField(default="", description="API 密钥") + """ + + model_config = ConfigDict( + extra="ignore", + validate_default=True, + toml_section="settings", # type: ignore[typeddict-unknown-key] + ) + + +def SettingsField( + default: Any = PydanticUndefined, + *, + hot: bool = False, + description: str = "", + **kwargs: Any, +) -> FieldInfo: + """Wrap ``pydantic.Field`` with an additional ``hot`` marker. + + The *hot* flag is stored in ``json_schema_extra`` so it appears in the + generated JSON Schema and can be inspected at runtime. + + Args: + default: Default value for the field. + hot: Whether this field supports runtime hot-update. + description: Human-readable description. + **kwargs: Forwarded to ``pydantic.Field``. + """ + json_schema_extra = kwargs.pop("json_schema_extra", None) + if callable(json_schema_extra): + original_extra = json_schema_extra + + def _with_hot(schema: dict[str, Any]) -> None: + original_extra(schema) + schema["hot"] = hot + + json_schema_extra = _with_hot + else: + json_schema_extra = dict(json_schema_extra or {}) + json_schema_extra["hot"] = hot + field = Field( + default=default, + description=description, + json_schema_extra=json_schema_extra, + **kwargs, + ) + field.metadata.append(("__neko_hot__", hot)) + return field + + +def get_hot_fields(settings_cls: type[PluginSettings]) -> set[str]: + """Return the set of field names marked ``hot=True`` in *settings_cls*. + + Fields without an explicit ``hot`` marker are treated as non-hot. + """ + hot_names: set[str] = set() + for name, field_info in settings_cls.model_fields.items(): + if any( + isinstance(entry, tuple) and entry[:1] == ("__neko_hot__",) and bool(entry[1]) + for entry in field_info.metadata + ): + hot_names.add(name) + continue + extra = field_info.json_schema_extra + if isinstance(extra, dict) and extra.get("hot") is True: + hot_names.add(name) + return hot_names + + +def create_settings_safe( + settings_cls: type[PluginSettings], + config_section: dict[str, Any] | None, +) -> PluginSettings: + """Create a *settings_cls* instance with per-field fallback on errors. + + If the entire *config_section* validates cleanly, return the instance + directly. Otherwise, for each field that fails validation, fall back + to its declared default and emit a logger warning. + + Args: + settings_cls: A ``PluginSettings`` subclass. + config_section: The raw config dict (e.g. from ``[settings]`` in + plugin.toml). ``None`` or empty dict means "use all defaults". + + Returns: + A validated *settings_cls* instance. + """ + data: dict[str, Any] = dict(config_section) if config_section else {} + + # Fast path: try full validation first. + try: + return settings_cls.model_validate(data) + except ValidationError: + pass + + # Slow path: validate field-by-field, falling back to defaults. + base_defaults = _defaults_dict(settings_cls) + safe_data: dict[str, Any] = {} + for name, field_info in settings_cls.model_fields.items(): + if name not in data: + # Field not in config — let pydantic use its default. + continue + + value = data[name] + # Try validating just this single field by constructing a minimal dict. + probe: dict[str, Any] = {name: value} + try: + settings_cls.model_validate( + {**base_defaults, **probe}, + ) + safe_data[name] = value + except ValidationError as exc: + _field_default = field_info.default + has_default_factory = field_info.default_factory is not None + if _field_default is PydanticUndefined and not has_default_factory: + # Required field with no default — we must include the bad + # value and let the final validation decide. + safe_data[name] = value + else: + default_label = "" if has_default_factory else _field_default + logger.warning( + "PluginSettings field '{}' validation failed, using default ({})", + name, + default_label, + ) + # Omit from safe_data so pydantic uses the declared default. + + try: + return settings_cls.model_validate({**base_defaults, **safe_data}) + except ValidationError: + # Last resort: all defaults. + logger.warning( + "PluginSettings full fallback to defaults after per-field recovery failed", + ) + return settings_cls.model_validate({}) + + +def _defaults_dict(settings_cls: type[PluginSettings]) -> dict[str, Any]: + """Build a dict of field-name → default for fields that have defaults.""" + defaults: dict[str, Any] = {} + for name, field_info in settings_cls.model_fields.items(): + if field_info.default is not PydanticUndefined: + defaults[name] = field_info.default + elif field_info.default_factory is not None: + try: + defaults[name] = field_info.get_default( + call_default_factory=True, + validated_data=defaults, + ) + except TypeError: + defaults[name] = field_info.get_default(call_default_factory=True) + return defaults + + +__all__ = [ + "PluginSettings", + "SettingsField", + "get_hot_fields", + "create_settings_safe", +] diff --git a/plugin/sdk/shared/core/decorators.py b/plugin/sdk/shared/core/decorators.py index f6f052ce17..15ebdb1cd6 100644 --- a/plugin/sdk/shared/core/decorators.py +++ b/plugin/sdk/shared/core/decorators.py @@ -18,7 +18,7 @@ NEKO_PLUGIN_TAG, PERSIST_ATTR, ) -from .events import EventMeta +from .events import EventMeta, QuickActionConfig from .result_contract import ( fields_from_schema, model_schema_from_type, @@ -28,11 +28,12 @@ from .types import InputSchema, JsonValue F = TypeVar("F", bound=Callable[..., object]) -EntryKind = Literal["service", "action", "hook", "custom", "lifecycle", "consumer", "timer"] +EntryKind = Literal["service", "action", "hook", "custom", "lifecycle", "consumer", "timer", "chat_command"] _SKIP_PARAMS = frozenset({"self", "cls", "kwargs", "_ctx", "args"}) _PARAMS_MODEL_ATTR = "_neko_params_model" _AUTO_INFER_PARAMS_ATTR = "_neko_auto_infer_params" _AUTO_INFER_LLM_RESULT_ATTR = "_neko_auto_infer_llm_result" +_QUICK_ACTION_CONFIG_ATTR = "_neko_quick_action_config" _TYPE_HINT_ERRORS = (NameError, TypeError, AttributeError, ValueError) _PY_TYPE_TO_JSON: dict[type, str] = { str: "string", @@ -367,6 +368,7 @@ def plugin_entry( llm_result_model: type | None = None, fields: type | None = None, metadata: dict[str, object] | None = None, + quick_action: bool = False, _localns: Mapping[str, object] | None = None, ) -> Callable[[F], F]: if input_schema is not None and params is not None: @@ -451,6 +453,12 @@ def _decorator(fn: F) -> F: metadata=_json_compatible_mapping(metadata), ) wrapped = _attach_event_meta(fn, meta) + # Read @quick_action config stored on fn by the decorator below + qa_cfg = getattr(fn, _QUICK_ACTION_CONFIG_ATTR, None) + if quick_action or isinstance(qa_cfg, QuickActionConfig): + meta.quick_action = True + if isinstance(qa_cfg, QuickActionConfig): + meta.quick_action_config = qa_cfg setattr(wrapped, _AUTO_INFER_PARAMS_ATTR, params is None and input_schema is None) setattr(wrapped, _AUTO_INFER_LLM_RESULT_ATTR, llm_model is None and normalized_llm_fields is None) if effective_params_model is not None: @@ -584,6 +592,39 @@ def entry(**kwargs: object) -> Callable[[F], F]: plugin = _PluginDecorators() +def quick_action( + *, + icon: str | None = None, + priority: int = 0, +) -> Callable[[F], F]: + """标记一个 entry 为快捷操作,在命令面板中优先展示。 + + 可放在 @plugin_entry 上方或下方。若 @plugin_entry 已经先执行,本装饰器 + 会直接更新已有 entry metadata。 + + 用法:: + + @plugin_entry(id="get_weather", name="获取天气") + @quick_action(icon="🌤️", priority=10) + async def get_weather(self, ...): ... + + Args: + icon: 面板中显示的图标(emoji),覆盖默认 ⚡ 图标 + priority: 排序权重(越大越靠前),默认 0 + """ + cfg = QuickActionConfig(icon=icon, priority=priority) + + def _decorator(fn: F) -> F: + meta = getattr(fn, EVENT_META_ATTR, None) + if isinstance(meta, EventMeta): + meta.quick_action = True + meta.quick_action_config = cfg + setattr(fn, _QUICK_ACTION_CONFIG_ATTR, cfg) + return fn + + return _decorator + + __all__ = [ "EVENT_META_ATTR", "EntryKind", @@ -602,6 +643,7 @@ def entry(**kwargs: object) -> Callable[[F], F]: "on_event", "plugin", "plugin_entry", + "quick_action", "replace_entry", "timer_interval", ] From f58b73b3cde4822bdb28511c458a4fe829e4b01e Mon Sep 17 00:00:00 2001 From: LocalBuilder Date: Tue, 12 May 2026 19:50:24 +0800 Subject: [PATCH 02/24] feat(actions): add chat actions backend - ActionAggregationService: collects actions from all providers - ActionExecutionService: dispatches action execution (hot-config toggle, plugin entry trigger, lifecycle control, navigation) - ListActionsProvider: maps plugin list_actions metadata to descriptors - SettingsActionProvider: auto-generates toggle/slider/dropdown actions from PluginSettings hot=True fields - SystemActionProvider: lifecycle start/stop/restart actions per plugin - BuiltinActionsProvider: static built-in actions (e.g. open settings) - PreferencesService: per-user action pinning/hiding persistence - ActionDescriptor / UserActionPreferences domain models - GET /actions, POST /actions/execute, GET/PUT /actions/preferences routes - GET /plugin/{id}/settings/schema endpoint - plugin_settings_resolver infrastructure helper - Wire actions_router into http_app and routes/__init__ --- plugin/server/application/actions/__init__.py | 28 ++ .../actions/aggregation_service.py | 55 +++ .../application/actions/builtin_provider.py | 95 ++++ .../application/actions/execution_service.py | 413 ++++++++++++++++++ .../actions/list_actions_provider.py | 175 ++++++++ .../actions/preferences_service.py | 97 ++++ .../application/actions/settings_provider.py | 305 +++++++++++++ .../application/actions/system_provider.py | 247 +++++++++++ plugin/server/domain/action_models.py | 89 ++++ plugin/server/domain/action_provider.py | 30 ++ plugin/server/http_app.py | 2 + .../plugin_settings_resolver.py | 67 +++ plugin/server/routes/__init__.py | 2 + plugin/server/routes/actions.py | 96 ++++ plugin/server/routes/config.py | 39 ++ 15 files changed, 1740 insertions(+) create mode 100644 plugin/server/application/actions/__init__.py create mode 100644 plugin/server/application/actions/aggregation_service.py create mode 100644 plugin/server/application/actions/builtin_provider.py create mode 100644 plugin/server/application/actions/execution_service.py create mode 100644 plugin/server/application/actions/list_actions_provider.py create mode 100644 plugin/server/application/actions/preferences_service.py create mode 100644 plugin/server/application/actions/settings_provider.py create mode 100644 plugin/server/application/actions/system_provider.py create mode 100644 plugin/server/domain/action_models.py create mode 100644 plugin/server/domain/action_provider.py create mode 100644 plugin/server/infrastructure/plugin_settings_resolver.py create mode 100644 plugin/server/routes/actions.py diff --git a/plugin/server/application/actions/__init__.py b/plugin/server/application/actions/__init__.py new file mode 100644 index 0000000000..aed48bda44 --- /dev/null +++ b/plugin/server/application/actions/__init__.py @@ -0,0 +1,28 @@ +"""Quick Actions Panel — action providers. + +This package contains the three ``ActionProvider`` implementations that +feed the ``ActionAggregationService``: + +* ``SettingsActionProvider`` – auto-generates instant actions from + ``PluginSettings`` hot fields. +* ``ListActionsProvider`` – maps plugin ``list_actions`` to + ``ActionDescriptor`` items. +* ``SystemActionProvider`` – generates lifecycle, toggle, entry, + static-UI and profile actions for every registered plugin. +""" + +from __future__ import annotations + +from plugin.server.application.actions.aggregation_service import ActionAggregationService +from plugin.server.application.actions.execution_service import ActionExecutionService +from plugin.server.application.actions.list_actions_provider import ListActionsProvider +from plugin.server.application.actions.settings_provider import SettingsActionProvider +from plugin.server.application.actions.system_provider import SystemActionProvider + +__all__ = [ + "ActionAggregationService", + "ActionExecutionService", + "ListActionsProvider", + "SettingsActionProvider", + "SystemActionProvider", +] diff --git a/plugin/server/application/actions/aggregation_service.py b/plugin/server/application/actions/aggregation_service.py new file mode 100644 index 0000000000..f267ac2965 --- /dev/null +++ b/plugin/server/application/actions/aggregation_service.py @@ -0,0 +1,55 @@ +"""ActionAggregationService — merge actions from all providers. + +Holds instances of the ``ActionProvider`` implementations and +exposes a single ``aggregate_actions`` method that collects, merges and +returns a flat list of ``ActionDescriptor`` items. + +Per-provider errors are caught and logged so that a single failing +provider does not block the others. +""" + +from __future__ import annotations + +from plugin.logging_config import get_logger +from plugin.server.application.actions.builtin_provider import BuiltinActionsProvider +from plugin.server.application.actions.list_actions_provider import ListActionsProvider +from plugin.server.application.actions.settings_provider import SettingsActionProvider +from plugin.server.application.actions.system_provider import SystemActionProvider +from plugin.server.domain.action_models import ActionDescriptor +from plugin.server.domain.action_provider import ActionProvider + +logger = get_logger("server.application.actions.aggregation") + + +class ActionAggregationService: + """Aggregate ``ActionDescriptor`` items from all providers.""" + + def __init__(self) -> None: + self._providers: list[ActionProvider] = [ + SettingsActionProvider(), + ListActionsProvider(), + SystemActionProvider(), + BuiltinActionsProvider(), + ] + + async def aggregate_actions( + self, + plugin_id: str | None = None, + ) -> list[ActionDescriptor]: + """Call all providers, merge results, return flat list. + + A single provider failure is logged as a warning and does not + prevent the other providers from contributing their actions. + """ + all_actions: list[ActionDescriptor] = [] + for provider in self._providers: + try: + actions = await provider.get_actions(plugin_id=plugin_id) + all_actions.extend(actions) + except Exception as exc: + logger.warning( + "ActionProvider {} failed: {}", + type(provider).__name__, + str(exc), + ) + return all_actions diff --git a/plugin/server/application/actions/builtin_provider.py b/plugin/server/application/actions/builtin_provider.py new file mode 100644 index 0000000000..16d1ec44b5 --- /dev/null +++ b/plugin/server/application/actions/builtin_provider.py @@ -0,0 +1,95 @@ +"""BuiltinActionsProvider — always-available commands for the command palette. + +These actions are available regardless of whether a plugin exposes entries +or settings. For every registered plugin, this provider generates +start / stop / reload buttons so the command palette is never empty. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from typing import Any + +from plugin.logging_config import get_logger +from plugin.server.domain.action_models import ActionDescriptor + +logger = get_logger("server.application.actions.builtin_provider") + + +def _collect_builtin_actions_sync( + plugin_id_filter: str | None = None, +) -> list[ActionDescriptor]: + """Collect built-in actions (called from a worker thread).""" + from plugin.core.state import state + + actions: list[ActionDescriptor] = [] + + # ── Plugin lifecycle: start/stop for every registered plugin ── + # This gives users a way to manage plugins from the command palette + # without the old full lifecycle control panel. + plugins_snapshot = state.get_plugins_snapshot_cached() + hosts_snapshot: dict[str, Any] = {} + with state.acquire_plugin_hosts_read_lock(): + hosts_snapshot = dict(state.plugin_hosts) + + for pid, meta_raw in plugins_snapshot.items(): + if plugin_id_filter is not None and pid != plugin_id_filter: + continue + + if not isinstance(meta_raw, Mapping): + continue + meta: dict[str, Any] = dict(meta_raw) + plugin_name = str(meta.get("name") or pid) + is_running = pid in hosts_snapshot + + if is_running: + actions.append(ActionDescriptor( + action_id=f"system:{pid}:stop", + type="instant", + label=f"停止 {plugin_name}", + description="", + category="插件管理", + plugin_id=pid, + control="button", + icon="⏹", + keywords=[pid, plugin_name, "stop", "停止", "关闭"], + priority=-10, + )) + actions.append(ActionDescriptor( + action_id=f"system:{pid}:reload", + type="instant", + label=f"重载 {plugin_name}", + description="", + category="插件管理", + plugin_id=pid, + control="button", + icon="🔄", + keywords=[pid, plugin_name, "reload", "重载", "刷新"], + priority=-10, + )) + else: + actions.append(ActionDescriptor( + action_id=f"system:{pid}:start", + type="instant", + label=f"启动 {plugin_name}", + description="", + category="插件管理", + plugin_id=pid, + control="button", + icon="▶", + keywords=[pid, plugin_name, "start", "启动", "开启"], + priority=-10, + )) + + return actions + + +class BuiltinActionsProvider: + """Generate always-available built-in ``ActionDescriptor`` items.""" + + async def get_actions( + self, + plugin_id: str | None = None, + ) -> list[ActionDescriptor]: + return await asyncio.to_thread(_collect_builtin_actions_sync, plugin_id) diff --git a/plugin/server/application/actions/execution_service.py b/plugin/server/application/actions/execution_service.py new file mode 100644 index 0000000000..d88c00b920 --- /dev/null +++ b/plugin/server/application/actions/execution_service.py @@ -0,0 +1,413 @@ +"""ActionExecutionService — execute actions by action_id. + +Parses the ``action_id`` prefix to determine the execution path and +delegates to the appropriate handler module. + +Execution paths: +* ``system:{plugin_id}:{action}`` → ``_SystemActionHandler`` +* ``{plugin_id}:settings:{field}`` → ``_SettingsActionHandler`` +* ``{plugin_id}:{action_id}`` → ``_ListActionHandler`` +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from plugin.logging_config import get_logger +from plugin.server.application.actions.aggregation_service import ActionAggregationService +from plugin.server.application.config.hot_update_service import hot_update_plugin_config +from plugin.server.application.plugins import PluginLifecycleService +from plugin.server.domain.action_models import ( + ActionDescriptor, + ActionExecuteResponse, +) +from plugin.server.domain.errors import ServerDomainError +from plugin.server.infrastructure.plugin_settings_resolver import resolve_settings_class + +logger = get_logger("server.application.actions.execution") + + +# ====================================================================== +# Shared helper +# ====================================================================== + +def _is_plugin_running(plugin_id: str) -> bool: + from plugin.core.state import state + + with state.acquire_plugin_hosts_read_lock(): + return plugin_id in state.plugin_hosts + + +async def _find_action( + aggregation: ActionAggregationService, + action_id: str, +) -> ActionDescriptor | None: + """Re-fetch the updated ActionDescriptor for *action_id*.""" + try: + # Determine plugin_id from action_id for efficient filtering + plugin_id: str | None = None + if action_id.startswith("system:"): + parts = action_id.split(":") + if len(parts) >= 2: + plugin_id = parts[1] + else: + parts = action_id.split(":") + if parts: + plugin_id = parts[0] + + all_actions = await aggregation.aggregate_actions(plugin_id=plugin_id) + for action in all_actions: + if action.action_id == action_id: + return action + except Exception as exc: + logger.warning("Failed to re-fetch action {}: {}", action_id, str(exc)) + return None + + +# ====================================================================== +# Settings handler +# ====================================================================== + +class _SettingsActionHandler: + """Handle ``{plugin_id}:settings:{field}`` actions via config hot-update.""" + + def __init__(self, aggregation: ActionAggregationService) -> None: + self._aggregation = aggregation + + async def execute( + self, + plugin_id: str, + field_name: str, + value: object, + ) -> ActionExecuteResponse: + settings_cls = await asyncio.to_thread( + resolve_settings_class, plugin_id, + ) + toml_section = "settings" + if settings_cls is not None: + toml_section = settings_cls.model_config.get("toml_section", "settings") + + updates: dict[str, object] = {toml_section: {field_name: value}} + + result = await hot_update_plugin_config( + plugin_id=plugin_id, + updates=updates, + mode="temporary", + ) + + updated_action = await _find_action( + self._aggregation, f"{plugin_id}:settings:{field_name}", + ) + message = ( + result.get("message", "Config hot-updated successfully") + if isinstance(result, dict) + else "Config hot-updated successfully" + ) + + return ActionExecuteResponse( + success=True, + action=updated_action, + message=str(message), + ) + + +# ====================================================================== +# System handler +# ====================================================================== + +class _SystemActionHandler: + """Handle ``system:{plugin_id}:{action}`` actions.""" + + def __init__( + self, + lifecycle: PluginLifecycleService, + ) -> None: + self._lifecycle = lifecycle + + async def execute( + self, + action_id: str, + value: object, + ) -> ActionExecuteResponse: + parts = action_id.split(":") + if len(parts) < 3: + raise ServerDomainError( + code="ACTION_NOT_FOUND", + message=f"Action '{action_id}' not found", + status_code=404, + details={"action_id": action_id}, + ) + + plugin_id = parts[1] + action = parts[2] + + handler = self._DISPATCH.get(action) + if handler is not None: + return await handler(self, plugin_id, action_id, value) + + # entry:{entry_id} + if action == "entry" and len(parts) >= 4: + entry_id = ":".join(parts[3:]) + return await self._entry_toggle(plugin_id, entry_id, action_id, value) + + raise ServerDomainError( + code="ACTION_NOT_FOUND", + message=f"Action '{action_id}' not found", + status_code=404, + details={"action_id": action_id}, + ) + + # -- Lifecycle actions -- + # These return action=None because the frontend does a full + # fetchChatActions() refresh after every execute anyway. + + async def _start( + self, plugin_id: str, action_id: str, value: object, + ) -> ActionExecuteResponse: + result = await self._lifecycle.start_plugin(plugin_id) + return ActionExecuteResponse( + success=True, + message=str(result.get("message", "Plugin started")), + ) + + async def _stop( + self, plugin_id: str, action_id: str, value: object, + ) -> ActionExecuteResponse: + result = await self._lifecycle.stop_plugin(plugin_id) + return ActionExecuteResponse( + success=True, + message=str(result.get("message", "Plugin stopped")), + ) + + async def _reload( + self, plugin_id: str, action_id: str, value: object, + ) -> ActionExecuteResponse: + result = await self._lifecycle.reload_plugin(plugin_id) + return ActionExecuteResponse( + success=True, + message=str(result.get("message", "Plugin reloaded")), + ) + + async def _toggle( + self, plugin_id: str, action_id: str, value: object, + ) -> ActionExecuteResponse: + running = await asyncio.to_thread(_is_plugin_running, plugin_id) + if running: + result = await self._lifecycle.stop_plugin(plugin_id) + msg = "Plugin stopped" + else: + result = await self._lifecycle.start_plugin(plugin_id) + msg = "Plugin started" + return ActionExecuteResponse( + success=True, + message=str(result.get("message", msg)), + ) + + async def _profile( + self, plugin_id: str, action_id: str, value: object, + ) -> ActionExecuteResponse: + if not isinstance(value, str) or not value.strip(): + raise ServerDomainError( + code="INVALID_ARGUMENT", + message="Profile name must be a non-empty string", + status_code=400, + details={"plugin_id": plugin_id}, + ) + + profile_name = value.strip() + + try: + from plugin.config.service import set_plugin_active_profile + + await asyncio.to_thread(set_plugin_active_profile, plugin_id, profile_name) + except Exception as exc: + raise ServerDomainError( + code="PLUGIN_PROFILE_ACTIVATE_FAILED", + message=f"Failed to set active profile '{profile_name}'", + status_code=500, + details={ + "plugin_id": plugin_id, + "profile_name": profile_name, + "error": str(exc), + }, + ) from exc + + try: + await self._lifecycle.reload_plugin(plugin_id) + except Exception as exc: + logger.warning( + "Profile switched but reload failed for plugin {}: {}", + plugin_id, + str(exc), + ) + + return ActionExecuteResponse( + success=True, + message=f"Profile switched to '{profile_name}'", + ) + + # -- Entry toggle -- + + async def _entry_toggle( + self, + plugin_id: str, + entry_id: str, + action_id: str, + value: object, + ) -> ActionExecuteResponse: + from plugin.core.state import state + + # Resolve the host — required for both trigger and toggle paths. + host = None + with state.acquire_plugin_hosts_read_lock(): + host = state.plugin_hosts.get(plugin_id) + + if host is None: + raise ServerDomainError( + code="PLUGIN_NOT_RUNNING", + message=f"Plugin '{plugin_id}' is not running", + status_code=400, + details={"plugin_id": plugin_id}, + ) + + # ── Button-type entries (value != bool): trigger execution via IPC ── + if not isinstance(value, bool): + # value may be a dict of parameters from the frontend form, + # or null for entries without input_schema. + args: dict = value if isinstance(value, dict) else {} + try: + trigger = getattr(host, "trigger", None) + if trigger is not None: + result = await trigger(entry_id, args) + msg = str(result) if result is not None else f"Entry '{entry_id}' executed" + else: + raise ServerDomainError( + code="ENTRY_TRIGGER_UNSUPPORTED", + message=f"Plugin host for '{plugin_id}' does not support trigger", + status_code=501, + details={"plugin_id": plugin_id, "entry_id": entry_id}, + ) + except Exception as exc: + raise ServerDomainError( + code="ENTRY_TRIGGER_FAILED", + message=f"Failed to trigger entry '{entry_id}': {exc}", + status_code=500, + details={ + "plugin_id": plugin_id, + "entry_id": entry_id, + "error": str(exc), + }, + ) from exc + + return ActionExecuteResponse( + success=True, + message=msg, + ) + + # ── Toggle-type entries (value == bool): enable/disable service ── + enable = value + + try: + method_name = "enable_entry" if enable else "disable_entry" + method = getattr(host, method_name, None) + if method is not None: + await asyncio.to_thread(method, entry_id) + else: + raise ServerDomainError( + code="ENTRY_TOGGLE_UNSUPPORTED", + message=f"Plugin host for '{plugin_id}' does not support {method_name}", + status_code=501, + details={"plugin_id": plugin_id, "entry_id": entry_id}, + ) + except Exception as exc: + raise ServerDomainError( + code="ENTRY_TOGGLE_FAILED", + message=f"Failed to {'enable' if enable else 'disable'} entry '{entry_id}'", + status_code=500, + details={ + "plugin_id": plugin_id, + "entry_id": entry_id, + "error": str(exc), + }, + ) from exc + + return ActionExecuteResponse( + success=True, + message=f"Entry '{entry_id}' {'enabled' if enable else 'disabled'}", + ) + + # Dispatch table — avoids long if/elif chains + _DISPATCH: dict[str, Any] = { + "start": _start, + "stop": _stop, + "reload": _reload, + "toggle": _toggle, + "profile": _profile, + } + + +# ====================================================================== +# List action handler +# ====================================================================== + +class _ListActionHandler: + """Handle ``{plugin_id}:{action_id}`` list actions.""" + + async def execute( + self, + action_id: str, + value: object, + ) -> ActionExecuteResponse: + # List action IPC execution is not yet implemented. + raise ServerDomainError( + code="ACTION_NOT_IMPLEMENTED", + message=f"List action '{action_id}' execution is not yet implemented", + status_code=501, + details={"action_id": action_id}, + ) + + +# ====================================================================== +# Public facade +# ====================================================================== + +class ActionExecutionService: + """Execute actions identified by ``action_id``. + + Delegates to specialised handler classes based on the action_id prefix. + """ + + def __init__(self) -> None: + self._lifecycle = PluginLifecycleService() + self._aggregation = ActionAggregationService() + self._settings_handler = _SettingsActionHandler(self._aggregation) + self._system_handler = _SystemActionHandler(self._lifecycle) + self._list_action_handler = _ListActionHandler() + + async def execute( + self, + action_id: str, + value: object = None, + ) -> ActionExecuteResponse: + """Parse *action_id* and dispatch to the correct handler.""" + + # system:{plugin_id}:{action} + if action_id.startswith("system:"): + return await self._system_handler.execute(action_id, value) + + # {plugin_id}:settings:{field} + parts = action_id.split(":", 2) + if len(parts) == 3 and parts[1] == "settings": + return await self._settings_handler.execute(parts[0], parts[2], value) + + # {plugin_id}:{action_id} (list_action) + if len(parts) >= 2: + return await self._list_action_handler.execute(action_id, value) + + raise ServerDomainError( + code="ACTION_NOT_FOUND", + message=f"Action '{action_id}' not found", + status_code=404, + details={"action_id": action_id}, + ) diff --git a/plugin/server/application/actions/list_actions_provider.py b/plugin/server/application/actions/list_actions_provider.py new file mode 100644 index 0000000000..39bfb9e932 --- /dev/null +++ b/plugin/server/application/actions/list_actions_provider.py @@ -0,0 +1,175 @@ +"""ListActionsProvider — map plugin ``list_actions`` to ActionDescriptors. + +Each plugin may register a set of *list_actions* in its metadata (stored +in ``state.plugins[pid]``). This provider reads them and converts each +entry into the appropriate ``ActionDescriptor`` based on its ``kind``. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from typing import Any + +from plugin.logging_config import get_logger +from plugin.server.domain.action_models import ActionDescriptor + +logger = get_logger("server.application.actions.list_actions_provider") + + +# --------------------------------------------------------------------------- +# Kind → ActionDescriptor mapping +# --------------------------------------------------------------------------- + +_NAVIGATION_KINDS = frozenset({"ui", "url", "route"}) +_CHAT_INJECT_KIND = "chat_inject" + + +def _safe_priority(value: Any) -> int: + if value is None: + return 0 + try: + return int(value) + except (TypeError, ValueError): + logger.debug("Ignoring invalid list_action priority: {}", value) + return 0 + + +def _safe_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes", "on"}: + return True + if normalized in {"false", "0", "no", "off", ""}: + return False + return bool(value) + + +def _map_list_action( + plugin_id: str, + plugin_name: str, + action: dict[str, Any], +) -> ActionDescriptor | None: + """Convert a single list_action dict to an ``ActionDescriptor``.""" + action_id = action.get("id") + if not isinstance(action_id, str) or not action_id.strip(): + return None + + kind = str(action.get("kind", "")).strip().lower() + label = str(action.get("label") or action_id) + description = str(action.get("description") or "") + full_action_id = f"{plugin_id}:{action_id}" + quick = _safe_bool(action.get("quick_action", False)) + icon_override = action.get("icon") + priority_override = action.get("priority") + + if kind == _CHAT_INJECT_KIND: + target = action.get("target") + if not isinstance(target, str) or not target.strip(): + inject_text = f"@{plugin_id} /{action_id}" + else: + inject_text = target + return ActionDescriptor( + action_id=full_action_id, + type="chat_inject", + label=label, + description=description, + category=plugin_name, + plugin_id=plugin_id, + inject_text=inject_text, + icon=icon_override or "📎", + keywords=[plugin_id, plugin_name, action_id, label], + quick_action=quick, + priority=_safe_priority(priority_override), + ) + + if kind in _NAVIGATION_KINDS: + raw_target = action.get("target") + if not isinstance(raw_target, str) or not raw_target.strip(): + logger.debug( + "Skipping navigation list_action with invalid target plugin_id={} action_id={}", + plugin_id, + action_id, + ) + return None + target = raw_target.strip() + open_in = action.get("open_in") + if open_in not in ("new_tab", "same_tab"): + open_in = "new_tab" + return ActionDescriptor( + action_id=full_action_id, + type="navigation", + label=label, + description=description, + category=plugin_name, + plugin_id=plugin_id, + target=target, + open_in=open_in, + icon=icon_override or "↗", + keywords=[plugin_id, plugin_name, action_id, label], + quick_action=quick, + priority=_safe_priority(priority_override), + ) + + if kind: + logger.debug( + "Skipping non-routable list_action plugin_id={} action_id={} kind={}", + plugin_id, + action_id, + kind, + ) + return None + + +# --------------------------------------------------------------------------- +# Synchronous core (runs in thread) +# --------------------------------------------------------------------------- + +def _collect_list_actions_sync( + plugin_id_filter: str | None = None, +) -> list[ActionDescriptor]: + """Collect list_actions-derived descriptors (called from a worker thread).""" + from plugin.core.state import state + + plugins_snapshot = state.get_plugins_snapshot_cached() + actions: list[ActionDescriptor] = [] + + for pid, meta_raw in plugins_snapshot.items(): + if plugin_id_filter is not None and pid != plugin_id_filter: + continue + + if not isinstance(meta_raw, Mapping): + continue + meta: dict[str, Any] = dict(meta_raw) + plugin_name = str(meta.get("name") or pid) + + # list_actions may be stored under the "list_actions" key in metadata + raw_list_actions = meta.get("list_actions") + if not isinstance(raw_list_actions, (list, tuple)): + continue + + for raw_action in raw_list_actions: + if not isinstance(raw_action, Mapping): + continue + action_dict = dict(raw_action) + descriptor = _map_list_action(pid, plugin_name, action_dict) + if descriptor is not None: + actions.append(descriptor) + + return actions + + +# --------------------------------------------------------------------------- +# Public provider +# --------------------------------------------------------------------------- + +class ListActionsProvider: + """Generate ``ActionDescriptor`` items from plugin ``list_actions``.""" + + async def get_actions( + self, + plugin_id: str | None = None, + ) -> list[ActionDescriptor]: + return await asyncio.to_thread(_collect_list_actions_sync, plugin_id) diff --git a/plugin/server/application/actions/preferences_service.py b/plugin/server/application/actions/preferences_service.py new file mode 100644 index 0000000000..3ded5872b9 --- /dev/null +++ b/plugin/server/application/actions/preferences_service.py @@ -0,0 +1,97 @@ +"""UserActionPreferences persistence service. + +Stores per-user command palette preferences (pinned / hidden / recent) +in a JSON file under the plugin config directory. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +from plugin.logging_config import get_logger +from plugin.server.domain.action_models import UserActionPreferences + +logger = get_logger("server.application.actions.preferences") + +_MAX_RECENT = 10 + + +def _preferences_path() -> Path: + """Return the path to the preferences JSON file.""" + from plugin.settings import USER_PLUGIN_CONFIG_ROOT + + return Path(USER_PLUGIN_CONFIG_ROOT) / ".action_preferences.json" + + +def _load_sync() -> UserActionPreferences: + """Load preferences from disk (called from worker thread).""" + path = _preferences_path() + if not path.exists(): + return UserActionPreferences() + try: + data = json.loads(path.read_text(encoding="utf-8")) + return UserActionPreferences.model_validate(data) + except Exception as exc: + logger.warning("Failed to load action preferences: {}", str(exc)) + return UserActionPreferences() + + +def _save_sync(prefs: UserActionPreferences) -> None: + """Atomically save preferences to disk (called from worker thread). + + Writes to a temporary file first, then renames to the target path. + This prevents half-written JSON on crash. Raises on failure so the + caller can propagate the error to the client. + """ + import os + import tempfile + + path = _preferences_path() + path.parent.mkdir(parents=True, exist_ok=True) + content = json.dumps(prefs.model_dump(), ensure_ascii=False, indent=2) + + # Write to a temp file in the same directory, then atomic rename. + fd, tmp_path = tempfile.mkstemp( + dir=str(path.parent), + prefix=".action_prefs_", + suffix=".tmp", + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp_path, str(path)) + except BaseException: + # Clean up the temp file on any failure + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +class PreferencesService: + """Manage user action preferences (pinned / hidden / recent).""" + + def __init__(self) -> None: + self._write_lock = asyncio.Lock() + + async def load(self) -> UserActionPreferences: + return await asyncio.to_thread(_load_sync) + + async def save(self, prefs: UserActionPreferences) -> UserActionPreferences: + async with self._write_lock: + prefs.recent = prefs.recent[:_MAX_RECENT] + await asyncio.to_thread(_save_sync, prefs) + return prefs + + async def touch_recent(self, action_id: str) -> None: + """Move *action_id* to the front of the recent list.""" + async with self._write_lock: + prefs = await asyncio.to_thread(_load_sync) + if action_id in prefs.recent: + prefs.recent.remove(action_id) + prefs.recent.insert(0, action_id) + prefs.recent = prefs.recent[:_MAX_RECENT] + await asyncio.to_thread(_save_sync, prefs) diff --git a/plugin/server/application/actions/settings_provider.py b/plugin/server/application/actions/settings_provider.py new file mode 100644 index 0000000000..d65e754130 --- /dev/null +++ b/plugin/server/application/actions/settings_provider.py @@ -0,0 +1,305 @@ +"""SettingsActionProvider — auto-generate instant actions from PluginSettings. + +For every running plugin that defines a ``PluginSettings`` subclass, this +provider inspects each ``hot=True`` field and emits an ``ActionDescriptor`` +with the appropriate control type (toggle / slider / number / dropdown). +""" + +from __future__ import annotations + +import asyncio +import enum +import typing +from collections.abc import Mapping +from typing import Any, get_args, get_origin + +from plugin.logging_config import get_logger +from plugin.server.domain.action_models import ActionDescriptor +from plugin.server.infrastructure.plugin_settings_resolver import resolve_settings_class + +logger = get_logger("server.application.actions.settings_provider") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _is_hot(field_info: Any) -> bool: + if any( + isinstance(entry, tuple) and entry[:1] == ("__neko_hot__",) and bool(entry[1]) + for entry in getattr(field_info, "metadata", []) + ): + return True + extra = field_info.json_schema_extra + return isinstance(extra, dict) and extra.get("hot") is True + + +def _get_constraint(field_info: Any, name: str) -> float | None: + """Read a Pydantic v2 numeric constraint from field metadata.""" + # Pydantic v2 stores constraints in field_info.metadata as annotated types + for item in getattr(field_info, "metadata", []): + val = getattr(item, name, None) + if val is not None: + return float(val) + return None + + +def _get_enum_options(field_info: Any, annotation: Any) -> list[str] | None: + """Extract enum / Literal string options from a field.""" + # 1. Check json_schema_extra for explicit enum list + extra = field_info.json_schema_extra + if isinstance(extra, dict): + enum_vals = extra.get("enum") + if isinstance(enum_vals, (list, tuple)) and enum_vals: + return [str(v) for v in enum_vals] + + # 2. Check if annotation is a Literal type + origin = get_origin(annotation) + if origin is typing.Literal: + args = get_args(annotation) + if args: + return [str(a) for a in args] + + # 3. Check if annotation is an enum.Enum subclass + if isinstance(annotation, type) and issubclass(annotation, enum.Enum): + return [str(m.value) for m in annotation] + + return None + + +def _resolve_annotation(annotation: Any) -> type | None: + """Unwrap Optional / Union to get the core type. + + Handles both ``typing.Union[X, None]`` (Python 3.9) and + ``X | None`` (PEP 604, Python 3.10+). + """ + import types as _types + + origin = get_origin(annotation) + if origin is typing.Union or isinstance(annotation, _types.UnionType): + args = [a for a in get_args(annotation) if a is not type(None)] + return args[0] if args else None + return annotation + + +def _build_descriptor_for_field( + plugin_id: str, + plugin_name: str, + field_name: str, + field_info: Any, + annotation: Any, + current_value: Any, +) -> ActionDescriptor | None: + """Map a single PluginSettings field to an ActionDescriptor (or None).""" + core_type = _resolve_annotation(annotation) + + label = field_info.description or field_name + base = dict( + action_id=f"{plugin_id}:settings:{field_name}", + type="instant", + label=label, + description=field_info.description or "", + category=plugin_name, + plugin_id=plugin_id, + keywords=[plugin_id, plugin_name, field_name, label], + ) + + # --- bool → toggle --- + if core_type is bool: + return ActionDescriptor( + **base, + control="toggle", + current_value=bool(current_value) if current_value is not None else False, + icon="🔘", + ) + + # --- int / float → slider or number --- + if core_type in (int, float): + ge = _get_constraint(field_info, "ge") + le = _get_constraint(field_info, "le") + gt = _get_constraint(field_info, "gt") + lt = _get_constraint(field_info, "lt") + + # Resolve effective min/max from ge/gt and le/lt + eff_min = ge if ge is not None else gt + eff_max = le if le is not None else lt + if core_type is int: + if ge is None and gt is not None: + eff_min = gt + 1 + if le is None and lt is not None: + eff_max = lt - 1 + + if eff_min is not None and eff_max 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=eff_min, + max=eff_max, + step=step, + icon="🎚", + ) + + # number fallback — one or both bounds missing + return ActionDescriptor( + **base, + control="number", + current_value=current_value, + min=eff_min, + max=eff_max, + icon="🔢", + ) + + # --- str with enum / Literal → dropdown --- + if core_type is str: + options = _get_enum_options(field_info, annotation) + if options: + return ActionDescriptor( + **base, + control="dropdown", + current_value=current_value, + options=options, + icon="📋", + ) + # str without enum → text input + return ActionDescriptor( + **base, + control="text", + current_value=current_value, + icon="✏️", + ) + + # --- Enum subclass → dropdown --- + if isinstance(core_type, type) and issubclass(core_type, enum.Enum): + options = [str(m.value) for m in core_type] + if isinstance(current_value, enum.Enum): + display_value = str(current_value.value) + elif current_value is not None: + display_value = str(current_value) + else: + display_value = None + return ActionDescriptor( + **base, + control="dropdown", + current_value=display_value, + options=options, + icon="📋", + ) + + # --- Literal (non-str) → dropdown --- + origin = get_origin(core_type) + if origin is typing.Literal: + args = get_args(core_type) + if args: + return ActionDescriptor( + **base, + control="dropdown", + current_value=str(current_value) if current_value is not None else None, + options=[str(a) for a in args], + icon="📋", + ) + + # Unsupported type → skip + return None + + +# --------------------------------------------------------------------------- +# Synchronous core (runs in thread) +# --------------------------------------------------------------------------- + +def _collect_settings_actions_sync( + plugin_id_filter: str | None = None, +) -> list[ActionDescriptor]: + """Collect settings-derived actions (called from a worker thread).""" + from plugin.core.state import state + + plugins_snapshot = state.get_plugins_snapshot_cached() + with state.acquire_plugin_hosts_read_lock(): + hosts_snapshot: dict[str, Any] = dict(state.plugin_hosts) + + actions: list[ActionDescriptor] = [] + + for pid, meta_raw in plugins_snapshot.items(): + if plugin_id_filter is not None and pid != plugin_id_filter: + continue + + # Only running plugins (must have a host) + host = hosts_snapshot.get(pid) + if host is None: + continue + + if not isinstance(meta_raw, Mapping): + continue + meta: dict[str, Any] = dict(meta_raw) + + plugin_name = str(meta.get("name") or pid) + + # Resolve the PluginSettings class + settings_cls = resolve_settings_class(pid, host=host) + if settings_cls is None: + continue + + # Read current effective config for this plugin's toml_section + toml_section = settings_cls.model_config.get("toml_section", "settings") + + # Read current effective config (includes temporary hot-updates), + # falling back to TOML file if effective config is unavailable. + current_section: dict[str, Any] = {} + try: + effective = getattr(host, "get_effective_config", None) + if callable(effective): + cfg = effective() + if isinstance(cfg, dict): + section = cfg.get(toml_section) + if isinstance(section, Mapping): + current_section = dict(section) + if not current_section: + from plugin.config.service import load_plugin_config + config_data = load_plugin_config(pid, validate=False) + if isinstance(config_data, dict): + inner = config_data.get("config", config_data) + section = inner.get(toml_section) if isinstance(inner, Mapping) else None + if isinstance(section, Mapping): + current_section = dict(section) + except Exception as exc: + logger.debug( + "Failed to load config for plugin {} section {}: {}", + pid, toml_section, repr(exc), + ) + + # Iterate fields + 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) + + return actions + + +# --------------------------------------------------------------------------- +# Public provider +# --------------------------------------------------------------------------- + +class SettingsActionProvider: + """Generate ``ActionDescriptor`` items from ``PluginSettings`` hot fields.""" + + async def get_actions( + self, + plugin_id: str | None = None, + ) -> list[ActionDescriptor]: + return await asyncio.to_thread(_collect_settings_actions_sync, plugin_id) diff --git a/plugin/server/application/actions/system_provider.py b/plugin/server/application/actions/system_provider.py new file mode 100644 index 0000000000..def4f0ae20 --- /dev/null +++ b/plugin/server/application/actions/system_provider.py @@ -0,0 +1,247 @@ +"""SystemActionProvider — entry actions and UI navigation for running plugins. + +For every registered *running* plugin this provider generates: + +* Button actions for non-service entries (action, hook, timer, chat_command, …) +* Navigation action for plugins with static UI + +Lifecycle management (start/stop/reload/toggle) and service toggles are +intentionally excluded — those belong in the plugin management dashboard, +not in the chat command palette. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from plugin.logging_config import get_logger +from plugin.sdk.shared.i18n import load_plugin_i18n_from_meta, resolve_i18n_refs +from plugin.server.domain.action_models import ActionDescriptor + +logger = get_logger("server.application.actions.system_provider") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _to_bool(value: object, *, default: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return default + + +def _has_static_ui(meta: dict[str, Any]) -> bool: + """Check whether a plugin has a usable static UI directory.""" + static_ui_obj = meta.get("static_ui_config") + if not isinstance(static_ui_obj, Mapping): + return False + enabled = _to_bool(static_ui_obj.get("enabled"), default=False) + if not enabled: + return False + directory = static_ui_obj.get("directory") + if not isinstance(directory, str) or not directory: + return False + index_file = static_ui_obj.get("index_file", "index.html") + if not isinstance(index_file, str) or not index_file: + index_file = "index.html" + p = Path(directory) + return p.is_dir() and (p / index_file).is_file() + + +def _resolve_default_locale() -> str: + try: + from utils.language_utils import get_global_language_full + + return str(get_global_language_full() or "en") + except Exception: + return "en" + + +def _resolve_plugin_i18n(value: object, plugin_meta: Mapping[str, object], *, locale: str | None = None) -> object: + return resolve_i18n_refs( + value, + load_plugin_i18n_from_meta(plugin_meta), + locale=locale or _resolve_default_locale(), + ) + + +def _get_entries_for_plugin( + plugin_id: str, + handlers_snapshot: dict[str, Any], + plugin_meta: Mapping[str, object] | None = None, + locale: str | None = None, +) -> list[dict[str, Any]]: + """Extract entry info dicts for *plugin_id* from the event_handlers snapshot.""" + entries: list[dict[str, Any]] = [] + seen: set[str] = set() + + for key, handler in handlers_snapshot.items(): + if not isinstance(key, str): + continue + + if key.startswith(f"{plugin_id}."): + entry_id = key[len(plugin_id) + 1:] + elif key.startswith(f"{plugin_id}:plugin_entry:"): + entry_id = key[len(f"{plugin_id}:plugin_entry:"):] + else: + continue + + if entry_id in seen: + continue + seen.add(entry_id) + + meta = getattr(handler, "meta", None) + entry_name = getattr(meta, "name", entry_id) if meta else entry_id + entry_kind = getattr(meta, "kind", "action") if meta else "action" + entry_description = getattr(meta, "description", "") if meta else "" + entry_input_schema = getattr(meta, "input_schema", None) if meta else None + is_quick = bool(getattr(meta, "quick_action", False)) if meta else False + qa_config = getattr(meta, "quick_action_config", None) if meta else None + + entry_dict: dict[str, Any] = { + "id": entry_id, + "name": entry_name, + "kind": entry_kind, + "description": entry_description, + "input_schema": entry_input_schema, + "quick_action": is_quick, + "quick_action_icon": getattr(qa_config, "icon", None) if qa_config else None, + "quick_action_priority": getattr(qa_config, "priority", 0) if qa_config else 0, + } + if plugin_meta is not None: + resolved = _resolve_plugin_i18n(entry_dict, plugin_meta, locale=locale) + if isinstance(resolved, dict): + entry_dict = resolved + entries.append(entry_dict) + + return entries + + +# --------------------------------------------------------------------------- +# Synchronous core (runs in thread) +# --------------------------------------------------------------------------- + +def _collect_system_actions_sync( + plugin_id_filter: str | None = None, +) -> list[ActionDescriptor]: + """Collect user-facing actions from running plugins (called from a worker thread).""" + from plugin.core.state import state + + plugins_snapshot = state.get_plugins_snapshot_cached() + hosts_snapshot: dict[str, Any] = {} + with state.acquire_plugin_hosts_read_lock(): + hosts_snapshot = dict(state.plugin_hosts) + handlers_snapshot = state.get_event_handlers_snapshot_cached() + + actions: list[ActionDescriptor] = [] + + for pid, meta_raw in plugins_snapshot.items(): + if plugin_id_filter is not None and pid != plugin_id_filter: + continue + + if not isinstance(meta_raw, Mapping): + continue + meta: dict[str, Any] = dict(meta_raw) + plugin_name_obj = _resolve_plugin_i18n(meta.get("name") or pid, meta) + plugin_name = str(plugin_name_obj or pid) + is_running = pid in hosts_snapshot + + # ── Entry actions (running plugins only) ── + # Only non-service entries are exposed as user-facing commands. + # Services are background processes managed via the admin dashboard. + if is_running: + entries = _get_entries_for_plugin(pid, handlers_snapshot, plugin_meta=meta) + for entry in entries: + entry_kind = entry.get("kind", "action") + + # Skip service entries — they are not user-facing commands + if entry_kind == "service": + continue + + entry_id = entry["id"] + entry_name = entry.get("name", entry_id) + entry_desc = entry.get("description", "") + + # chat_command entries appear as slash commands (chat_inject) + if entry_kind == "chat_command": + actions.append(ActionDescriptor( + action_id=f"system:{pid}:entry:{entry_id}", + type="chat_inject", + label=str(entry_name), + description=str(entry_desc), + category=plugin_name, + plugin_id=pid, + inject_text=f"@{plugin_name} /{entry_id}", + icon="📎", + keywords=[pid, plugin_name, str(entry_name), entry_id], + quick_action=entry.get("quick_action", False), + )) + continue + + is_quick = entry.get("quick_action", False) + qa_icon = entry.get("quick_action_icon") + qa_priority = entry.get("quick_action_priority", 0) + + raw_schema = entry.get("input_schema") + schema: dict[str, object] | None = None + if isinstance(raw_schema, dict): + props = raw_schema.get("properties") + if isinstance(props, dict) and len(props) > 0: + schema = raw_schema + + actions.append(ActionDescriptor( + action_id=f"system:{pid}:entry:{entry_id}", + type="instant", + label=str(entry_name), + description=str(entry_desc), + category=plugin_name, + plugin_id=pid, + control="button", + input_schema=schema, + icon=str(qa_icon) if qa_icon else "⚡", + keywords=[pid, plugin_name, str(entry_name)], + quick_action=bool(is_quick), + priority=int(qa_priority) if is_quick else 0, + )) + + # ── Static UI navigation ── + if _has_static_ui(meta): + from config import USER_PLUGIN_SERVER_PORT as _ui_port + actions.append(ActionDescriptor( + action_id=f"system:{pid}:open_ui", + type="navigation", + label=f"打开 {plugin_name} UI", + description="", + category=plugin_name, + plugin_id=pid, + target=f"http://127.0.0.1:{_ui_port}/plugin/{pid}/ui/", + open_in="new_tab", + icon="↗", + keywords=[pid, plugin_name, "ui", "界面"], + )) + + return actions + + +# --------------------------------------------------------------------------- +# Public provider +# --------------------------------------------------------------------------- + +class SystemActionProvider: + """Generate user-facing ``ActionDescriptor`` items for running plugins.""" + + async def get_actions( + self, + plugin_id: str | None = None, + ) -> list[ActionDescriptor]: + return await asyncio.to_thread(_collect_system_actions_sync, plugin_id) diff --git a/plugin/server/domain/action_models.py b/plugin/server/domain/action_models.py new file mode 100644 index 0000000000..d02b918f32 --- /dev/null +++ b/plugin/server/domain/action_models.py @@ -0,0 +1,89 @@ +"""Pydantic models for the Command Palette. + +Defines the ``ActionDescriptor`` that describes a single action item, +plus request/response envelopes for the execute endpoint. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +class ActionDescriptor(BaseModel): + """描述一个命令面板中的操作项。""" + + action_id: str = Field(description="唯一标识,格式: {plugin_id}:{field_or_action_id}") + type: Literal["instant", "chat_inject", "navigation"] + label: str = Field(description="显示名称") + description: str = Field(default="", description="描述文本") + category: str = Field(description="分组名称,默认为插件 name") + plugin_id: str = Field(description="来源插件 ID,系统级操作为 'system'") + + # Instant action fields (type == "instant") + control: Literal[ + "toggle", + "button", + "dropdown", + "number", + "slider", + "text", + "plugin_lifecycle", + "entry_toggle", + ] | None = None + current_value: object = None + options: list[str] | None = None # dropdown only + min: float | None = None # number/slider only + max: float | None = None # number/slider only + step: float | None = None # number/slider only + disabled: bool = False + + # Entry action fields (control == "button" with input_schema) + input_schema: dict[str, object] | None = None + + # Chat inject fields (type == "chat_inject") + inject_text: str | None = None + + # Navigation fields (type == "navigation") + target: str | None = None + open_in: Literal["new_tab", "same_tab"] | None = None + + # Command palette metadata + keywords: list[str] = Field(default_factory=list, description="搜索关键词(多语言)") + icon: str | None = Field(default=None, description="Emoji 或图标标识") + priority: int = Field(default=0, description="排序权重,越大越靠前") + quick_action: bool = Field(default=False, description="插件自注册的快捷操作,在面板中优先展示") + section: Literal["pinned", "recent", "commands"] | None = Field( + default=None, description="面板分区,由前端根据偏好计算", + ) + + +class ActionExecuteRequest(BaseModel): + """POST /chat/actions/{action_id}/execute 请求体。""" + + value: object = None + + +class ActionExecuteResponse(BaseModel): + """POST /chat/actions/{action_id}/execute 响应体。""" + + success: bool + action: ActionDescriptor | None = None + message: str = "" + + +class UserActionPreferences(BaseModel): + """用户对命令面板操作项的个性化偏好。""" + + pinned: list[str] = Field(default_factory=list, description="置顶的 action_id 列表") + hidden: list[str] = Field(default_factory=list, description="隐藏的 action_id 列表") + recent: list[str] = Field(default_factory=list, description="最近使用的 action_id,最多 10 条") + + +__all__ = [ + "ActionDescriptor", + "ActionExecuteRequest", + "ActionExecuteResponse", + "UserActionPreferences", +] diff --git a/plugin/server/domain/action_provider.py b/plugin/server/domain/action_provider.py new file mode 100644 index 0000000000..5fdc93e187 --- /dev/null +++ b/plugin/server/domain/action_provider.py @@ -0,0 +1,30 @@ +"""ActionProvider protocol for the Command Palette. + +Each provider is responsible for generating ``ActionDescriptor`` items +from a single source (PluginSettings, list_actions, system entries, builtin, …). +""" + +from __future__ import annotations + +from typing import Protocol + +from plugin.server.domain.action_models import ActionDescriptor + + +class ActionProvider(Protocol): + """操作项提供者协议。 + + 每个 provider 负责从一个来源生成 ``ActionDescriptor`` 列表。 + """ + + async def get_actions( + self, + plugin_id: str | None = None, + ) -> list[ActionDescriptor]: + """返回操作项列表。*plugin_id* 为 ``None`` 时返回所有。""" + ... + + +__all__ = [ + "ActionProvider", +] diff --git a/plugin/server/http_app.py b/plugin/server/http_app.py index f27822dfab..051146c9f4 100644 --- a/plugin/server/http_app.py +++ b/plugin/server/http_app.py @@ -20,6 +20,7 @@ from plugin.server.lifecycle import shutdown as lifecycle_shutdown from plugin.server.lifecycle import startup as lifecycle_startup from plugin.server.routes import ( + actions_router, config_router, frontend_router, health_router, @@ -168,6 +169,7 @@ async def _frontend_cache_headers( app.include_router(frontend_router) app.include_router(websocket_router) app.include_router(plugin_ui_router) + app.include_router(actions_router) # galgame_plugin install routes 是可选模块:plugin import-time 副作用 # (缺 dxcam / tesseract / textractor 等可选依赖、非 Windows 运行时等)不应让 # 整个 plugin server 启动失败;失败时只让 install 端点返回 404。 diff --git a/plugin/server/infrastructure/plugin_settings_resolver.py b/plugin/server/infrastructure/plugin_settings_resolver.py new file mode 100644 index 0000000000..b557f12849 --- /dev/null +++ b/plugin/server/infrastructure/plugin_settings_resolver.py @@ -0,0 +1,67 @@ +"""Resolve a plugin's ``PluginSettings`` subclass from its entry_point. + +Centralises the "import plugin class → get .Settings → check it's a +PluginSettings subclass" logic that was previously duplicated across +``execution_service``, ``settings_provider``, and ``hot_update_service``. +""" + +from __future__ import annotations + +import importlib +from collections.abc import Mapping +from typing import Any + +from plugin.logging_config import get_logger + +logger = get_logger("server.infrastructure.plugin_settings_resolver") + + +def resolve_settings_class( + plugin_id: str, + *, + host: Any = None, +) -> type | None: + """Return the ``PluginSettings`` subclass for *plugin_id*, or ``None``. + + Resolution order: + 1. If *host* is provided, read ``host.entry_point``. + 2. Otherwise fall back to the plugins snapshot metadata + (``entry_point`` or ``entry`` key). + + Returns ``None`` when the plugin has no ``Settings`` inner class or + when the import fails (backward-compat plugins). + """ + from plugin.core.state import state + from plugin.sdk.plugin.settings import PluginSettings + + # Determine entry_point + entry_point: str | None = None + if host is not None: + entry_point = getattr(host, "entry_point", None) + if not entry_point: + plugins_snapshot = state.get_plugins_snapshot_cached() + meta_raw = plugins_snapshot.get(plugin_id) + if isinstance(meta_raw, Mapping): + entry_point = meta_raw.get("entry_point") or meta_raw.get("entry") + + if not entry_point or not isinstance(entry_point, str): + return None + + try: + module_path, class_name = entry_point.split(":", 1) + mod = importlib.import_module(module_path) + plugin_cls = getattr(mod, class_name, None) + if plugin_cls is None: + return None + settings_cls = getattr(plugin_cls, "Settings", None) + if settings_cls is None: + return None + if isinstance(settings_cls, type) and issubclass(settings_cls, PluginSettings): + return settings_cls + except Exception: + logger.debug( + "Failed to resolve PluginSettings for plugin_id={}, entry_point={}", + plugin_id, + entry_point, + ) + return None diff --git a/plugin/server/routes/__init__.py b/plugin/server/routes/__init__.py index ccc79b270a..c1799b7f45 100644 --- a/plugin/server/routes/__init__.py +++ b/plugin/server/routes/__init__.py @@ -13,6 +13,7 @@ from plugin.server.routes.frontend import router as frontend_router from plugin.server.routes.websocket import router as websocket_router from plugin.server.routes.plugin_ui import router as plugin_ui_router +from plugin.server.routes.actions import router as actions_router from plugin.server.routes.plugin_cli import router as plugin_cli_router from plugin.server.routes.llm_tools import router as llm_tools_router @@ -27,6 +28,7 @@ 'frontend_router', 'websocket_router', 'plugin_ui_router', + 'actions_router', 'plugin_cli_router', 'llm_tools_router', ] diff --git a/plugin/server/routes/actions.py b/plugin/server/routes/actions.py new file mode 100644 index 0000000000..f9305fff19 --- /dev/null +++ b/plugin/server/routes/actions.py @@ -0,0 +1,96 @@ +""" +命令面板路由 + +GET /chat/actions — 获取所有操作项 +GET /chat/actions/preferences — 获取用户偏好 +POST /chat/actions/preferences — 保存用户偏好 +POST /chat/actions/{action_id}/execute — 执行操作 +""" +from typing import Optional + +from fastapi import APIRouter, Query + +from plugin.logging_config import get_logger +from plugin.server.application.actions.aggregation_service import ActionAggregationService +from plugin.server.application.actions.execution_service import ActionExecutionService +from plugin.server.application.actions.preferences_service import PreferencesService +from plugin.server.domain.action_models import ( + ActionExecuteRequest, + ActionExecuteResponse, + UserActionPreferences, +) +from plugin.server.domain.errors import ServerDomainError +from plugin.server.infrastructure.auth import require_admin +from plugin.server.infrastructure.error_mapping import raise_http_from_domain + +router = APIRouter() +logger = get_logger("server.routes.actions") +aggregation_service = ActionAggregationService() +execution_service = ActionExecutionService() +preferences_service = PreferencesService() + + +@router.get("/chat/actions") +async def get_chat_actions( + plugin_id: Optional[str] = Query(default=None), + _: str = require_admin, +) -> dict[str, object]: + try: + actions = await aggregation_service.aggregate_actions(plugin_id=plugin_id) + prefs = await preferences_service.load() + return { + "actions": [a.model_dump(exclude_none=True) for a in actions], + "preferences": prefs.model_dump(), + } + except ServerDomainError as error: + raise_http_from_domain(error, logger=logger) + + +# ── Preferences routes MUST be registered before the {action_id:path} +# route below, otherwise FastAPI would match "preferences" as an action_id. + +@router.get("/chat/actions/preferences") +async def get_action_preferences( + _: str = require_admin, +) -> dict[str, object]: + try: + prefs = await preferences_service.load() + return prefs.model_dump() + except ServerDomainError as error: + raise_http_from_domain(error, logger=logger) + + +@router.post("/chat/actions/preferences") +async def save_action_preferences( + payload: UserActionPreferences, + _: str = require_admin, +) -> dict[str, object]: + try: + prefs = await preferences_service.save(payload) + return prefs.model_dump() + except ServerDomainError as error: + raise_http_from_domain(error, logger=logger) + + +# ── Execute route uses {action_id:path} — must come last. + +@router.post("/chat/actions/{action_id:path}/execute") +async def execute_chat_action( + action_id: str, + payload: ActionExecuteRequest, + _: str = require_admin, +) -> dict[str, object]: + try: + result: ActionExecuteResponse = await execution_service.execute( + action_id=action_id, + value=payload.value, + ) + # Auto-update recent list on successful execution + if result.success: + try: + await preferences_service.touch_recent(action_id) + except Exception: + pass # Non-critical — don't fail the execute response + return result.model_dump(exclude_none=True) + except ServerDomainError as error: + raise_http_from_domain(error, logger=logger) diff --git a/plugin/server/routes/config.py b/plugin/server/routes/config.py index df5fddb303..22c937b517 100644 --- a/plugin/server/routes/config.py +++ b/plugin/server/routes/config.py @@ -215,3 +215,42 @@ async def hot_update_plugin_config_endpoint( ) except ServerDomainError as error: raise_http_from_domain(error, logger=logger) + + +@router.get("/plugin/{plugin_id}/settings/schema") +async def get_plugin_settings_schema_endpoint( + plugin_id: str, + _: str = require_admin, +) -> dict[str, object]: + """Return the PluginSettings JSON Schema for a plugin.""" + try: + return _resolve_plugin_settings_schema(plugin_id) + except ServerDomainError as error: + raise_http_from_domain(error, logger=logger) + + +def _resolve_plugin_settings_schema(plugin_id: str) -> dict[str, object]: + """Resolve the PluginSettings class for *plugin_id* and return its schema.""" + from plugin.core.state import state + from plugin.server.infrastructure.plugin_settings_resolver import resolve_settings_class + + empty: dict[str, object] = { + "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(), + } From 402032b39a08316bbbf495768e5c9b8ff86eb552 Mon Sep 17 00:00:00 2001 From: LocalBuilder Date: Tue, 12 May 2026 19:51:11 +0800 Subject: [PATCH 03/24] feat(actions): proxy chat actions through main server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add actions_proxy_router that forwards /api/actions/* requests from the main FastAPI app to the plugin server, enabling the React chat frontend to access the actions API without CORS issues. Endpoints proxied: - GET /api/actions → list available actions - POST /api/actions/execute → execute an action by id - GET /api/actions/preferences → get user action preferences - PUT /api/actions/preferences → update user action preferences --- app/main_server.py | 2 + main_routers/actions_proxy_router.py | 153 +++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 main_routers/actions_proxy_router.py diff --git a/app/main_server.py b/app/main_server.py index cfb237687c..52ddf004eb 100644 --- a/app/main_server.py +++ b/app/main_server.py @@ -1463,6 +1463,7 @@ async def get_response(self, path, scope): from main_routers.workshop_router import router as workshop_router # noqa from main_routers.cookies_login_router import router as cookies_login_router # noqa from main_routers.game_router import router as game_router # noqa +from main_routers.actions_proxy_router import router as actions_proxy_router # noqa from main_routers.shared_state import init_shared_state # noqa @@ -1512,6 +1513,7 @@ async def beacon_shutdown(): app.include_router(galgame_router) app.include_router(game_router) app.include_router(cookies_login_router) # Cookies登录相关路由,放在最后以避免与其他API路由冲突 +app.include_router(actions_proxy_router) # Quick Actions Panel: 代理到插件服务器 app.include_router(pages_router) # 兜底路由需最后挂载 # 后台预加载任务 diff --git a/main_routers/actions_proxy_router.py b/main_routers/actions_proxy_router.py new file mode 100644 index 0000000000..5599d997d7 --- /dev/null +++ b/main_routers/actions_proxy_router.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +""" +Actions Proxy Router + +Proxies Command Palette requests from the main server to the user plugin +server, which owns the actual action providers. + +URL convention: routes declared WITHOUT trailing slash. See +``main_routers/characters_router.py`` docstring or +``.agent/rules/neko-guide.md`` for the project-wide convention. +""" +from __future__ import annotations + +import time +from typing import Any + +import httpx +from fastapi import APIRouter, Query, Request +from fastapi.responses import JSONResponse + +from config import USER_PLUGIN_BASE +from utils.logger_config import get_module_logger + +router = APIRouter(tags=["actions-proxy"]) +logger = get_module_logger(__name__, "Main") + +_USER_PLUGIN_DEFAULT_BASE = "http://127.0.0.1:48916" +_USER_PLUGIN_BASE_CACHE: tuple[str, float] = ("", 0.0) + + +def _proxy_response(resp: httpx.Response) -> JSONResponse: + """Return the plugin server response with its original status code.""" + try: + content = resp.json() + except ValueError: + content = {"detail": resp.text} + return JSONResponse(status_code=resp.status_code, content=content) + + +def _empty_actions_payload() -> dict[str, Any]: + return {"actions": [], "preferences": {"pinned": [], "hidden": [], "recent": []}} + + +def _empty_preferences_payload() -> dict[str, Any]: + return {"pinned": [], "hidden": [], "recent": []} + + +async def _resolve_user_plugin_base() -> str: + """Resolve the active user plugin server base URL. + + The configured base is normally correct. The fallback mirrors the agent + dashboard route so dev setups that still use the historical default port + remain reachable. + """ + global _USER_PLUGIN_BASE_CACHE + cached_base, cached_at = _USER_PLUGIN_BASE_CACHE + now = time.monotonic() + if cached_base and now - cached_at < 5.0: + return cached_base + + candidates: list[str] = [] + for value in (USER_PLUGIN_BASE, _USER_PLUGIN_DEFAULT_BASE): + normalized = str(value or "").rstrip("/") + if normalized and normalized not in candidates: + candidates.append(normalized) + + async with httpx.AsyncClient(timeout=0.45, proxy=None, trust_env=False) as client: + for base in candidates: + try: + response = await client.get(f"{base}/available") + if response.is_success: + _USER_PLUGIN_BASE_CACHE = (base, now) + return base + except Exception: + continue + + fallback = str(USER_PLUGIN_BASE or _USER_PLUGIN_DEFAULT_BASE).rstrip("/") + _USER_PLUGIN_BASE_CACHE = (fallback, now) + return fallback + + +@router.get("/chat/actions", response_model=None) +async def proxy_chat_actions( + plugin_id: str | None = Query(default=None), +) -> Any: + """Proxy GET /chat/actions to the user plugin server.""" + params: dict[str, str] = {} + if plugin_id: + params["plugin_id"] = plugin_id + try: + base = await _resolve_user_plugin_base() + async with httpx.AsyncClient(timeout=5.0, proxy=None, trust_env=False) as client: + resp = await client.get(f"{base}/chat/actions", params=params) + return _proxy_response(resp) + except Exception: + logger.debug("Failed to proxy GET /chat/actions", exc_info=True) + return _empty_actions_payload() + + +# Preferences routes must be registered before the {action_id:path} route. + + +@router.get("/chat/actions/preferences", response_model=None) +async def proxy_get_preferences() -> Any: + """Proxy GET /chat/actions/preferences to the user plugin server.""" + try: + base = await _resolve_user_plugin_base() + async with httpx.AsyncClient(timeout=5.0, proxy=None, trust_env=False) as client: + resp = await client.get(f"{base}/chat/actions/preferences") + return _proxy_response(resp) + except Exception: + logger.debug("Failed to proxy GET /chat/actions/preferences", exc_info=True) + return _empty_preferences_payload() + + +@router.post("/chat/actions/preferences", response_model=None) +async def proxy_save_preferences(request: Request) -> JSONResponse: + """Proxy POST /chat/actions/preferences to the user plugin server.""" + try: + body = await request.json() + except Exception: + body = {} + try: + base = await _resolve_user_plugin_base() + async with httpx.AsyncClient(timeout=5.0, proxy=None, trust_env=False) as client: + resp = await client.post(f"{base}/chat/actions/preferences", json=body) + return _proxy_response(resp) + except Exception as exc: + logger.warning("Failed to proxy POST /chat/actions/preferences: %s", exc) + return JSONResponse(status_code=502, content=_empty_preferences_payload()) + + +@router.post("/chat/actions/{action_id:path}/execute", response_model=None) +async def proxy_chat_action_execute( + action_id: str, + request: Request, +) -> JSONResponse: + """Proxy POST /chat/actions/{action_id}/execute to the user plugin server.""" + try: + body = await request.json() + except Exception: + body = {} + try: + base = await _resolve_user_plugin_base() + async with httpx.AsyncClient(timeout=10.0, proxy=None, trust_env=False) as client: + resp = await client.post(f"{base}/chat/actions/{action_id}/execute", json=body) + return _proxy_response(resp) + except Exception as exc: + logger.warning("Failed to proxy POST /chat/actions/%s/execute: %s", action_id, exc) + return JSONResponse( + status_code=502, + content={"success": False, "action": None, "message": str(exc)}, + ) From 205c781d2adbaacd98e7efba1780bf7f481b38af Mon Sep 17 00:00:00 2001 From: LocalBuilder Date: Tue, 12 May 2026 19:51:32 +0800 Subject: [PATCH 04/24] =?UTF-8?q?feat(chat):=20add=20Command=20Palette=20(?= =?UTF-8?q?=E2=8C=98K)=20actions=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommandPalette.tsx: searchable action list with keyboard navigation, category grouping, parameter input forms, execution feedback - message-schema.ts: typed message protocol between host and React chat - App.tsx: mount CommandPalette, handle toggle via postMessage - styles.css: palette overlay, animations, dark/light theme support - app-react-chat-window.js: bridge postMessage events to React chat - Tests for CommandPalette rendering and message schema validation --- frontend/react-neko-chat/src/App.tsx | 112 ++- .../react-neko-chat/src/CommandPalette.tsx | 932 ++++++++++++++++++ .../src/message-schema.test.ts | 61 ++ .../react-neko-chat/src/message-schema.ts | 59 ++ frontend/react-neko-chat/src/styles.css | 642 +++++++++++- .../src/test/CommandPalette.test.tsx | 347 +++++++ static/app-react-chat-window.js | 126 ++- 7 files changed, 2275 insertions(+), 4 deletions(-) create mode 100644 frontend/react-neko-chat/src/CommandPalette.tsx create mode 100644 frontend/react-neko-chat/src/test/CommandPalette.test.tsx diff --git a/frontend/react-neko-chat/src/App.tsx b/frontend/react-neko-chat/src/App.tsx index a7fca5235b..2830bd8e86 100644 --- a/frontend/react-neko-chat/src/App.tsx +++ b/frontend/react-neko-chat/src/App.tsx @@ -14,6 +14,7 @@ import { type ChoicePrompt, type ChoicePromptSource, } from './message-schema'; +import CommandPalette, { type CommandItem, type UserPreferences } from './CommandPalette'; export type ChatWindowProps = ChatWindowSchemaProps & { onMessageAction?: (message: ChatMessage, action: MessageAction) => void; @@ -27,6 +28,12 @@ export type ChatWindowProps = ChatWindowSchemaProps & { onTranslateToggle?: () => void; onGalgameModeToggle?: () => void; onGalgameOptionSelect?: (option: GalgameOption) => void; + quickActions?: CommandItem[]; + quickActionsPreferences?: UserPreferences; + quickActionsLoading?: boolean; + onQuickActionExecute?: (actionId: string, value: unknown) => Promise; + onQuickActionsRequest?: () => void; + onQuickActionsPreferencesChange?: (prefs: UserPreferences) => void; // Generic ChoicePrompt(mini-game invite 等通用三选项框架)。 // galgame mode 现有路径继续走 galgameOptions / onGalgameOptionSelect(BC); // 本框架先只承载 mini_game_invite,未来可把 galgame 也迁过来。 @@ -608,6 +615,12 @@ export default function App({ onTranslateToggle, onGalgameModeToggle, onGalgameOptionSelect, + quickActions, + quickActionsPreferences, + quickActionsLoading, + onQuickActionExecute, + onQuickActionsRequest, + onQuickActionsPreferencesChange, choicePrompt = null, onChoiceSelect, rollbackDraft, @@ -616,6 +629,8 @@ export default function App({ }: ChatWindowProps) { const [draft, setDraft] = useState(''); const [toolMenuOpen, setToolMenuOpen] = useState(false); + const [quickActionsPanelOpen, setQuickActionsPanelOpen] = useState(false); + const [quickActionsSlashMode, setQuickActionsSlashMode] = useState(false); // 当 composer-bottom-bar 宽度 < 阈值时,把右侧 4 个工具按钮折叠成 ··· 菜单。 // 用四态机让进出过渡都跑完动画再切稳态: // expanded → collapsing (右→左级联收起) → compact (··· 入场) @@ -669,6 +684,7 @@ export default function App({ const [floatingHearts, setFloatingHearts] = useState([]); const [floatingFistDrops, setFloatingFistDrops] = useState([]); const submittingRef = useRef(false); + const composerTextareaRef = useRef(null); const lastRollbackKeyRef = useRef(''); const lastToolCursorResetKeyRef = useRef(''); const canSubmit = !composerDisabled && (draft.trim().length > 0 || composerAttachments.length > 0); @@ -1516,6 +1532,8 @@ export default function App({ useEffect(() => { if (composerHidden || composerDisabled) { clearActiveCursorToolSelection(); + setQuickActionsPanelOpen(false); + setQuickActionsSlashMode(false); } }, [clearActiveCursorToolSelection, composerHidden, composerDisabled]); @@ -1531,6 +1549,43 @@ export default function App({ clearGlobalToolCursorState(); }, []); + const handleQuickActionInjectText = useCallback((text: string) => { + setDraft(prev => { + if (!prev || !prev.trim()) return text; + return `${prev} ${text}`; + }); + setQuickActionsPanelOpen(false); + setQuickActionsSlashMode(false); + requestAnimationFrame(() => { + composerTextareaRef.current?.focus(); + }); + }, []); + + const handleQuickActionNavigate = useCallback((target: string, openIn: string) => { + if (!target) return; + if (target.startsWith('//')) return; + const isRelative = target.startsWith('/') || target.startsWith('./') || target.startsWith('../'); + const isHttp = /^https?:\/\//i.test(target); + if (!isRelative && !isHttp) return; + if (openIn === 'same_tab') { + window.location.href = target; + } else { + window.open(target, '_blank', 'noopener,noreferrer'); + } + }, []); + + const handleQuickActionExecute = useCallback(async ( + actionId: string, + value: unknown, + ): Promise => { + if (!onQuickActionExecute) return null; + return onQuickActionExecute(actionId, value); + }, [onQuickActionExecute]); + + const handleQuickActionsPreferencesChange = useCallback((prefs: UserPreferences) => { + onQuickActionsPreferencesChange?.(prefs); + }, [onQuickActionsPreferencesChange]); + function submitDraft() { if (composerDisabled) return; if (submittingRef.current) return; @@ -1545,8 +1600,32 @@ export default function App({ } } - // 右侧 3 个工具按钮:在 compact 与 normal 两种布局中复用同一份 JSX, + // 右侧工具按钮:在 compact 与 normal 两种布局中复用同一份 JSX, // 既避免重复,也保证 ref/事件绑定在两种模式下行为一致。 + const quickActionsButtonNode = ( + + ); + const translateButtonNode = (