diff --git a/.gitignore b/.gitignore index ed7714776..88977c18f 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ dist/ # MDI font subset (generated by dashboard/scripts/subset-mdi-font.mjs) dashboard/src/assets/mdi-subset/*.woff dashboard/src/assets/mdi-subset/*.woff2 +.planning diff --git a/astrbot/core/plugin/__init__.py b/astrbot/core/plugin/__init__.py deleted file mode 100644 index b6686d02a..000000000 --- a/astrbot/core/plugin/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -"""AstrBot Plugin System (ABP) - -ABP is the plugin communication protocol for AstrBot, supporting: -- In-process and out-of-process loading modes -- Full plugin lifecycle management -- Tool calling, message handling, event subscriptions -- JSON-RPC 2.0 communication -""" - -from .client import PluginClient -from .const import ABP_VERSION, get_error_message -from .manager import PluginManager -from .models import ( - EventNotification, - HandleEventResult, - MessageChainItem, - MessageEvent, - PluginCapabilities, - PluginConfig, - PluginInfo, - PluginMetadata, - SenderInfo, - Tool, - ToolContent, - ToolResult, -) -from .transport import HttpTransport, StdioTransport, UnixSocketTransport - -__all__ = [ - "ABP_VERSION", - "EventNotification", - "HandleEventResult", - "HttpTransport", - "MessageChainItem", - "MessageEvent", - "PluginCapabilities", - "PluginClient", - "PluginConfig", - "PluginInfo", - "PluginManager", - "PluginMetadata", - "SenderInfo", - "StdioTransport", - "Tool", - "ToolContent", - "ToolResult", - "UnixSocketTransport", - "get_error_message", -] diff --git a/astrbot/core/plugin/client.py b/astrbot/core/plugin/client.py deleted file mode 100644 index 26f6ca1b3..000000000 --- a/astrbot/core/plugin/client.py +++ /dev/null @@ -1,319 +0,0 @@ -"""ABP Plugin Client for out-of-process communication""" - -import asyncio -import logging -from typing import Any - -from .const import ( - ABP_VERSION, -) -from .models import ( - HandleEventResult, - InitializeParams, - InitializeResult, - MessageChainItem, - PluginCapabilities, - PluginConfig, - PluginMetadata, - Tool, - ToolContent, - ToolResult, -) -from .transport import HttpTransport, StdioTransport, Transport, UnixSocketTransport - -logger = logging.getLogger("astrbot.plugin.client") - - -class PluginClient: - """Client for communicating with out-of-process plugins. - - This client implements the ABP protocol for plugin communication, - supporting JSON-RPC 2.0 over various transports. - """ - - def __init__(self, config: PluginConfig) -> None: - self._config = config - self._transport: Transport | None = None - self._initialized = False - self._capabilities = PluginCapabilities() - self._metadata: PluginMetadata | None = None - self._tools: list[Tool] = [] - - @property - def name(self) -> str: - return self._config.name - - @property - def is_initialized(self) -> bool: - return self._initialized - - @property - def capabilities(self) -> PluginCapabilities: - return self._capabilities - - @property - def metadata(self) -> PluginMetadata | None: - return self._metadata - - @property - def tools(self) -> list[Tool]: - return self._tools - - async def start( - self, process: asyncio.subprocess.Process | None = None - ) -> InitializeResult: - """Start and initialize the plugin. - - Args: - process: For stdio transport, the subprocess handle - - Returns: - InitializeResult from the plugin - """ - # Create transport based on config - if self._config.transport == "stdio": - if not process: - raise ValueError("stdio transport requires a subprocess") - self._transport = StdioTransport(process) - elif self._config.transport == "unix_socket": - if not self._config.url: - raise ValueError("unix_socket transport requires socket path in url") - self._transport = UnixSocketTransport(self._config.url) - await self._transport.connect() - elif self._config.transport == "http": - if not self._config.url: - raise ValueError("http transport requires URL") - self._transport = HttpTransport(self._config.url) - await self._transport.connect() - else: - raise ValueError(f"Unknown transport: {self._config.transport}") - - # Send initialize request - result = await self._send_initialize() - - # Parse result - self._capabilities = PluginCapabilities( - tools=result.capabilities.tools, - handlers=result.capabilities.handlers, - events=result.capabilities.events, - resources=result.capabilities.resources, - ) - self._metadata = result.metadata - self._initialized = True - - return result - - async def _send_initialize(self) -> InitializeResult: - """Send initialize request to plugin.""" - assert self._transport - - # Get data directories - from pathlib import Path - - from astrbot.core.utils.astrbot_path import get_astrbot_data_path - - data_path = Path(get_astrbot_data_path()) - plugin_data_path = data_path / "plugins" / self._config.name - - params = InitializeParams( - protocol_version=ABP_VERSION, - client_info={"name": "astrbot", "version": "4.25.0"}, - capabilities={ - "streaming": True, - "events": True, - }, - plugin_config={"user_config": {}}, - data_dirs={ - "root": str(data_path), - "plugin_data": str(plugin_data_path), - "temp": str(data_path / "temp"), - }, - ) - - result = await self._transport.send( - "initialize", - { - "protocolVersion": params.protocol_version, - "clientInfo": params.client_info, - "capabilities": params.capabilities, - "pluginConfig": params.plugin_config, - "dataDirs": params.data_dirs, - }, - ) - - return InitializeResult( - protocol_version=result.get("protocolVersion", ABP_VERSION), - server_info=result.get( - "serverInfo", {"name": self._config.name, "version": "1.0.0"} - ), - capabilities=PluginCapabilities( - tools=result.get("capabilities", {}).get("tools", False), - handlers=result.get("capabilities", {}).get("handlers", False), - events=result.get("capabilities", {}).get("events", False), - resources=result.get("capabilities", {}).get("resources", False), - ), - config_schema=result.get("configSchema"), - metadata=PluginMetadata( - display_name=result.get("metadata", {}).get("display_name"), - description=result.get("metadata", {}).get("description"), - author=result.get("metadata", {}).get("author"), - homepage=result.get("metadata", {}).get("homepage"), - support_platforms=result.get("metadata", {}).get( - "support_platforms", [] - ), - astrbot_version=result.get("metadata", {}).get("astrbot_version"), - ), - ) - - async def list_tools(self) -> list[Tool]: - """List available tools from plugin.""" - if not self._initialized: - raise RuntimeError("Plugin not initialized") - assert self._transport - - result = await self._transport.send("tools/list", {}) - - self._tools = [ - Tool( - name=t.get("name", ""), - description=t.get("description", ""), - input_schema=t.get("parameters", {}), - ) - for t in result.get("tools", []) - ] - - return self._tools - - async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> ToolResult: - """Call a tool on the plugin.""" - if not self._initialized: - raise RuntimeError("Plugin not initialized") - assert self._transport - - result = await self._transport.send( - "tools/call", - { - "name": tool_name, - "arguments": arguments, - }, - ) - - return ToolResult( - content=[ - ToolContent( - type=c.get("type", "text"), - text=c.get("text"), - image=c.get("image"), - url=c.get("url"), - ) - for c in result.get("content", []) - ] - ) - - async def handle_event( - self, event_type: str, event_data: dict[str, Any] - ) -> HandleEventResult: - """Handle an event with the plugin.""" - if not self._initialized: - raise RuntimeError("Plugin not initialized") - assert self._transport - - result = await self._transport.send( - "plugin.handle_event", - { - "event_type": event_type, - "event": event_data, - }, - ) - - return HandleEventResult( - handled=result.get("handled", False), - results=[ - MessageChainItem(type=r.get("type", "plain"), text=r.get("text")) - for r in result.get("results", []) - ], - stop_propagation=result.get("stop_propagation", False), - ) - - async def notify(self, event_type: str, data: dict[str, Any]) -> None: - """Send an event notification to the plugin.""" - if not self._initialized: - raise RuntimeError("Plugin not initialized") - assert self._transport - - await self._transport.notify( - "plugin.notify", - { - "event_type": event_type, - "data": data, - }, - ) - - async def stop(self) -> None: - """Stop the plugin.""" - if self._transport: - await self._transport.close() - self._transport = None - self._initialized = False - - async def reload(self) -> None: - """Reload the plugin (re-initialize without restarting process).""" - if self._transport: - await self._transport.send("plugin.reload", {}) - self._initialized = False - - async def update_config(self, user_config: dict[str, Any]) -> None: - """Update plugin configuration.""" - if not self._initialized: - raise RuntimeError("Plugin not initialized") - assert self._transport - - await self._transport.notify( - "plugin.config_update", - {"user_config": user_config}, - ) - - async def subscribe(self, event_type: str) -> None: - """Subscribe to an event type. - - Args: - event_type: Event type to subscribe to (e.g., "llm_request", "tool_called") - """ - if not self._initialized: - raise RuntimeError("Plugin not initialized") - assert self._transport - - await self._transport.send( - "plugin.subscribe", - {"event_type": event_type}, - ) - logger.debug(f"Plugin {self._config.name} subscribed to {event_type}") - - async def unsubscribe(self, event_type: str) -> None: - """Unsubscribe from an event type. - - Args: - event_type: Event type to unsubscribe from - """ - if not self._initialized: - raise RuntimeError("Plugin not initialized") - assert self._transport - - await self._transport.send( - "plugin.unsubscribe", - {"event_type": event_type}, - ) - logger.debug(f"Plugin {self._config.name} unsubscribed from {event_type}") - - async def send_notification(self, method: str, params: dict[str, Any]) -> None: - """Send a JSON-RPC notification (no response expected). - - Args: - method: The JSON-RPC method name - params: The method parameters - """ - if not self._initialized: - raise RuntimeError("Plugin not initialized") - assert self._transport - - await self._transport.notify(method, params) diff --git a/astrbot/core/plugin/const.py b/astrbot/core/plugin/const.py deleted file mode 100644 index e844d986f..000000000 --- a/astrbot/core/plugin/const.py +++ /dev/null @@ -1,59 +0,0 @@ -"""ABP Protocol Constants""" - -# Protocol version -ABP_VERSION = "1.0.0" - -# ABP Error codes (JSON-RPC 2.0 base + ABP extension) -# JSON-RPC standard errors -PARSE_ERROR: int = -32700 -INVALID_REQUEST: int = -32600 -METHOD_NOT_FOUND: int = -32601 -INVALID_PARAMS: int = -32602 -INTERNAL_ERROR: int = -32603 - -# ABP extension errors (-32200 to -32211) -PLUGIN_NOT_FOUND: int = -32200 -PLUGIN_NOT_READY: int = -32201 -PLUGIN_CRASHED: int = -32202 -TOOL_NOT_FOUND: int = -32203 -TOOL_CALL_FAILED: int = -32204 -HANDLER_NOT_FOUND: int = -32205 -HANDLER_ERROR: int = -32206 -EVENT_SUBSCRIBE_FAILED: int = -32207 -PERMISSION_DENIED: int = -32208 -CONFIG_ERROR: int = -32209 -DEPENDENCY_MISSING: int = -32210 -VERSION_MISMATCH: int = -32211 - -# Plugin load modes -LOAD_MODE_IN_PROCESS: str = "in_process" -LOAD_MODE_OUT_OF_PROCESS: str = "out_of_process" - -# Transport types -TRANSPORT_STDIO: str = "stdio" -TRANSPORT_UNIX_SOCKET: str = "unix_socket" -TRANSPORT_HTTP: str = "http" - - -def get_error_message(code: int) -> str: - """Get error message for error code.""" - messages = { - PARSE_ERROR: "Parse error - Invalid JSON", - INVALID_REQUEST: "Invalid request", - METHOD_NOT_FOUND: "Method not found", - INVALID_PARAMS: "Invalid params", - INTERNAL_ERROR: "Internal error", - PLUGIN_NOT_FOUND: "Plugin not found", - PLUGIN_NOT_READY: "Plugin not ready", - PLUGIN_CRASHED: "Plugin crashed", - TOOL_NOT_FOUND: "Tool not found", - TOOL_CALL_FAILED: "Tool call failed", - HANDLER_NOT_FOUND: "Handler not found", - HANDLER_ERROR: "Handler error", - EVENT_SUBSCRIBE_FAILED: "Event subscription failed", - PERMISSION_DENIED: "Permission denied", - CONFIG_ERROR: "Configuration error", - DEPENDENCY_MISSING: "Dependency missing", - VERSION_MISMATCH: "Version mismatch", - } - return messages.get(code, f"Unknown error code: {code}") diff --git a/astrbot/core/plugin/manager.py b/astrbot/core/plugin/manager.py deleted file mode 100644 index 051836bec..000000000 --- a/astrbot/core/plugin/manager.py +++ /dev/null @@ -1,344 +0,0 @@ -"""ABP Plugin Manager - -Manages plugin lifecycle, loading, and communication. -""" - -import asyncio -import logging -from pathlib import Path -from typing import Any - -from .client import PluginClient -from .const import LOAD_MODE_IN_PROCESS, LOAD_MODE_OUT_OF_PROCESS -from .models import PluginCapabilities, PluginConfig, PluginInfo, Tool - -logger = logging.getLogger("astrbot.plugin.manager") - - -class PluginManager: - """Manages ABP plugins. - - Responsible for: - - Plugin discovery and registration - - Plugin lifecycle (load, start, stop, reload) - - Tool routing across plugins - - Event distribution - """ - - def __init__(self) -> None: - self._plugins: dict[str, PluginClient] = {} - self._in_process_handlers: dict[str, Any] = {} - self._event_subscribers: dict[str, list[str]] = {} - self._initialized = False - - @property - def is_initialized(self) -> bool: - return self._initialized - - async def initialize( - self, - plugin_configs: list[PluginConfig], - data_root: Path, - ) -> None: - """Initialize the plugin manager and load plugins. - - Args: - plugin_configs: List of plugin configurations - data_root: Root data directory for AstrBot - """ - logger.info(f"Initializing plugin manager with {len(plugin_configs)} plugins") - - # Load each plugin - for config in plugin_configs: - await self.load_plugin(config, data_root) - - self._initialized = True - logger.info(f"Plugin manager initialized with {len(self._plugins)} plugins") - - async def load_plugin(self, config: PluginConfig, data_root: Path) -> None: - """Load a plugin. - - Args: - config: Plugin configuration - data_root: Root data directory - """ - logger.info(f"Loading plugin: {config.name} (mode: {config.load_mode})") - - if config.load_mode == LOAD_MODE_IN_PROCESS: - await self._load_in_process_plugin(config, data_root) - elif config.load_mode == LOAD_MODE_OUT_OF_PROCESS: - await self._load_out_of_process_plugin(config, data_root) - else: - raise ValueError(f"Unknown load mode: {config.load_mode}") - - async def _load_in_process_plugin( - self, config: PluginConfig, data_root: Path - ) -> None: - """Load an in-process plugin (Python module). - - For in-process plugins, we would import the module directly. - This is a placeholder for when the Rust FFI exposes plugin loading. - """ - logger.debug(f"Loading in-process plugin: {config.name}") - # In-process plugins would be loaded via Python import - # For now, just register a placeholder - self._in_process_handlers[config.name] = { - "config": config, - "capabilities": PluginCapabilities(), - "metadata": None, - "tools": [], - } - - async def _load_out_of_process_plugin( - self, config: PluginConfig, data_root: Path - ) -> None: - """Load an out-of-process plugin (subprocess). - - Args: - config: Plugin configuration - data_root: Root data directory - """ - if not config.command: - raise ValueError(f"Out-of-process plugin {config.name} requires 'command'") - - # Spawn subprocess - cmd = config.command - args = [cmd, *config.args] - - logger.debug(f"Spawning plugin process: {' '.join(args)}") - - process = await asyncio.create_subprocess_exec( - *args, - env={**asyncio.get_running_loop().run_in_executor, **config.env} - if config.env - else None, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - # Create client and start plugin - client = PluginClient(config) - await client.start(process=process) - - self._plugins[config.name] = client - logger.info(f"Loaded out-of-process plugin: {config.name}") - - async def unload_plugin(self, name: str) -> None: - """Unload a plugin. - - Args: - name: Plugin name - """ - if name in self._plugins: - await self._plugins[name].stop() - del self._plugins[name] - logger.info(f"Unloaded plugin: {name}") - - if name in self._in_process_handlers: - del self._in_process_handlers[name] - logger.info(f"Unloaded in-process plugin: {name}") - - async def start_plugin(self, name: str) -> None: - """Start a plugin (already loaded).""" - if name not in self._plugins: - raise KeyError(f"Plugin not found: {name}") - # Plugin is already started if it's in the registry - pass - - async def stop_plugin(self, name: str) -> None: - """Stop a plugin.""" - await self.unload_plugin(name) - - async def reload_plugin(self, name: str) -> None: - """Reload a plugin.""" - if name not in self._plugins: - raise KeyError(f"Plugin not found: {name}") - await self._plugins[name].reload() - logger.info(f"Reloaded plugin: {name}") - - async def update_plugin_config( - self, name: str, user_config: dict[str, Any] - ) -> None: - """Update plugin configuration. - - Args: - name: Plugin name - user_config: New user configuration - """ - if name in self._plugins: - await self._plugins[name].update_config(user_config) - elif name in self._in_process_handlers: - # In-process plugins handle config differently - pass - else: - raise KeyError(f"Plugin not found: {name}") - - def list_plugins(self) -> list[PluginInfo]: - """List all loaded plugins. - - Returns: - List of PluginInfo for each loaded plugin - """ - result: list[PluginInfo] = [] - - for name, client in self._plugins.items(): - result.append( - PluginInfo( - name=client.name, - version="1.0.0", - load_mode=client._config.load_mode, - capabilities=client.capabilities, - metadata=client.metadata, - tools_count=len(client.tools), - ) - ) - - for name, handler in self._in_process_handlers.items(): - result.append( - PluginInfo( - name=name, - version=handler["config"].version, - load_mode=LOAD_MODE_IN_PROCESS, - capabilities=handler["capabilities"], - metadata=handler["metadata"], - tools_count=len(handler["tools"]), - ) - ) - - return result - - def get_plugin(self, name: str) -> PluginInfo | None: - """Get info for a specific plugin. - - Args: - name: Plugin name - - Returns: - PluginInfo or None if not found - """ - for info in self.list_plugins(): - if info.name == name: - return info - return None - - def get_all_tools(self) -> list[tuple[str, Tool]]: - """Get all tools from all plugins. - - Returns: - List of (plugin_name, Tool) tuples - """ - tools: list[tuple[str, Tool]] = [] - - for name, client in self._plugins.items(): - for tool in client.tools: - tools.append((name, tool)) - - for name, handler in self._in_process_handlers.items(): - for tool in handler["tools"]: - tools.append((name, tool)) - - return tools - - async def call_tool( - self, plugin_name: str, tool_name: str, arguments: dict[str, Any] - ) -> Any: - """Call a tool on a specific plugin. - - Args: - plugin_name: Plugin name - tool_name: Tool name - arguments: Tool arguments - - Returns: - Tool result - """ - if plugin_name in self._plugins: - result = await self._plugins[plugin_name].call_tool(tool_name, arguments) - return result - elif plugin_name in self._in_process_handlers: - # In-process tool calling would go through Python directly - raise NotImplementedError("In-process tool calling not implemented") - else: - raise KeyError(f"Plugin not found: {plugin_name}") - - async def route_tool_call( - self, tool_name: str, arguments: dict[str, Any] - ) -> tuple[str, Any]: - """Route a tool call to the appropriate plugin. - - Args: - tool_name: Tool name - arguments: Tool arguments - - Returns: - Tuple of (plugin_name, tool_result) - """ - for name, client in self._plugins.items(): - if any(t.name == tool_name for t in client.tools): - result = await client.call_tool(tool_name, arguments) - return (name, result) - - for name, handler in self._in_process_handlers.items(): - if any(t.name == tool_name for t in handler["tools"]): - # In-process tool calling - raise NotImplementedError("In-process tool calling not implemented") - - raise KeyError(f"Tool not found: {tool_name}") - - async def subscribe_event(self, plugin_name: str, event_type: str) -> None: - """Subscribe a plugin to an event type. - - Args: - plugin_name: Plugin name - event_type: Event type to subscribe to - """ - if plugin_name not in self._plugins: - raise KeyError(f"Plugin not found: {plugin_name}") - - if event_type not in self._event_subscribers: - self._event_subscribers[event_type] = [] - if plugin_name not in self._event_subscribers[event_type]: - self._event_subscribers[event_type].append(plugin_name) - await self._plugins[plugin_name]._transport.send( - "plugin.subscribe", {"event_type": event_type} - ) - logger.info(f"Plugin {plugin_name} subscribed to {event_type}") - - async def unsubscribe_event(self, plugin_name: str, event_type: str) -> None: - """Unsubscribe a plugin from an event type. - - Args: - plugin_name: Plugin name - event_type: Event type to unsubscribe from - """ - if plugin_name not in self._plugins: - raise KeyError(f"Plugin not found: {plugin_name}") - - if event_type in self._event_subscribers: - if plugin_name in self._event_subscribers[event_type]: - self._event_subscribers[event_type].remove(plugin_name) - await self._plugins[plugin_name]._transport.send( - "plugin.unsubscribe", {"event_type": event_type} - ) - logger.info(f"Plugin {plugin_name} unsubscribed from {event_type}") - - async def broadcast_event(self, event_type: str, data: dict[str, Any]) -> None: - """Broadcast an event to all subscribed plugins. - - Args: - event_type: Event type - data: Event data - """ - if event_type in self._event_subscribers: - for plugin_name in self._event_subscribers[event_type]: - await self._plugins[plugin_name].notify(event_type, data) - - async def shutdown(self) -> None: - """Shutdown the plugin manager and all plugins.""" - logger.info("Shutting down plugin manager") - for name in list(self._plugins.keys()): - await self.unload_plugin(name) - self._in_process_handlers.clear() - self._event_subscribers.clear() - self._initialized = False diff --git a/astrbot/core/plugin/models.py b/astrbot/core/plugin/models.py deleted file mode 100644 index 7a18e3bc0..000000000 --- a/astrbot/core/plugin/models.py +++ /dev/null @@ -1,144 +0,0 @@ -"""ABP Protocol Data Models""" - -from dataclasses import dataclass, field -from typing import Any - - -@dataclass -class PluginCapabilities: - """Plugin capabilities.""" - - tools: bool = False - handlers: bool = False - events: bool = False - resources: bool = False - - -@dataclass -class PluginMetadata: - """Plugin metadata.""" - - display_name: str | None = None - description: str | None = None - author: str | None = None - homepage: str | None = None - support_platforms: list[str] = field(default_factory=list) - astrbot_version: str | None = None - - -@dataclass -class PluginConfig: - """Plugin configuration.""" - - name: str - version: str = "1.0.0" - load_mode: str = "in_process" - command: str | None = None - args: list[str] = field(default_factory=list) - env: dict[str, str] = field(default_factory=dict) - transport: str = "stdio" - url: str | None = None - - -@dataclass -class InitializeParams: - """Initialize request parameters.""" - - protocol_version: str - client_info: dict[str, str] - capabilities: dict[str, bool] - plugin_config: dict[str, Any] - data_dirs: dict[str, str] - - -@dataclass -class InitializeResult: - """Initialize response from plugin.""" - - protocol_version: str - server_info: dict[str, str] - capabilities: PluginCapabilities - config_schema: dict[str, Any] | None = None - metadata: PluginMetadata | None = None - - -@dataclass -class Tool: - """Tool definition.""" - - name: str - description: str = "" - input_schema: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class ToolContent: - """Tool result content.""" - - type: str - text: str | None = None - image: str | None = None - url: str | None = None - - -@dataclass -class ToolResult: - """Tool call result.""" - - content: list[ToolContent] = field(default_factory=list) - - -@dataclass -class SenderInfo: - """Message sender info.""" - - user_id: str - nickname: str = "" - - -@dataclass -class MessageChainItem: - """Message chain item.""" - - type: str - text: str | None = None - - -@dataclass -class MessageEvent: - """Message event.""" - - message_id: str - unified_msg_origin: str - message_str: str - sender: SenderInfo - message_chain: list[MessageChainItem] = field(default_factory=list) - - -@dataclass -class HandleEventResult: - """Handle event result.""" - - handled: bool - results: list[MessageChainItem] = field(default_factory=list) - stop_propagation: bool = False - - -@dataclass -class EventNotification: - """Event notification.""" - - event_type: str - data: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class PluginInfo: - """Plugin info for listing.""" - - name: str - version: str - load_mode: str - capabilities: PluginCapabilities - metadata: PluginMetadata | None = None - tools_count: int = 0 diff --git a/astrbot/core/plugin/transport.py b/astrbot/core/plugin/transport.py deleted file mode 100644 index 280087995..000000000 --- a/astrbot/core/plugin/transport.py +++ /dev/null @@ -1,231 +0,0 @@ -"""ABP Transport Implementations""" - -import asyncio -import json -import logging -from abc import ABC, abstractmethod -from typing import Any - -import anyio - -logger = logging.getLogger("astrbot.plugin.transport") - - -class Transport(ABC): - """Base transport class.""" - - @abstractmethod - async def send(self, method: str, params: dict[str, Any]) -> dict[str, Any]: - """Send JSON-RPC request and receive response.""" - pass - - @abstractmethod - async def notify(self, method: str, params: dict[str, Any]) -> None: - """Send JSON-RPC notification (no response).""" - pass - - @abstractmethod - async def close(self) -> None: - """Close the transport.""" - pass - - -class StdioTransport(Transport): - """Stdio transport for subprocess communication. - - Messages are sent as single-line JSON. - """ - - def __init__(self, process: asyncio.subprocess.Process) -> None: - self._process = process - self._lock = asyncio.Lock() - - async def send(self, method: str, params: dict[str, Any]) -> dict[str, Any]: - """Send request via stdin and read from stdout.""" - request = { - "jsonrpc": "2.0", - "id": str(id(params)), - "method": method, - "params": params, - } - line = json.dumps(request, ensure_ascii=False) - async with self._lock: - self._process.stdin.write((line + "\n").encode("utf-8")) - await self._process.stdin.drain() - response_line = await self._process.stdout.readline() - if not response_line: - raise RuntimeError("Plugin process closed unexpectedly") - response = json.loads(response_line.decode("utf-8")) - if "error" in response: - raise RuntimeError(f"JSON-RPC error: {response['error']}") - return response.get("result", {}) - - async def notify(self, method: str, params: dict[str, Any]) -> None: - """Send notification via stdin.""" - notification = { - "jsonrpc": "2.0", - "method": method, - "params": params, - } - line = json.dumps(notification, ensure_ascii=False) - async with self._lock: - self._process.stdin.write((line + "\n").encode("utf-8")) - await self._process.stdin.drain() - - async def close(self) -> None: - """Terminate the subprocess.""" - self._process.terminate() - try: - await asyncio.wait_for(self._process.wait(), timeout=5.0) - except asyncio.TimeoutError: - self._process.kill() - - -class UnixSocketTransport(Transport): - """Unix Socket transport. - - Uses Content-Length framing protocol. - """ - - def __init__(self, socket_path: str) -> None: - self._socket_path = socket_path - self._reader: anyio.abc.SocketStream | None = None - self._writer: anyio.abc.SocketStream | None = None - - async def connect(self) -> None: - """Connect to the Unix socket.""" - try: - self._reader, self._writer = await anyio.connect_unix(self._socket_path) - except Exception as e: - raise RuntimeError(f"Failed to connect to {self._socket_path}: {e}") - - async def send(self, method: str, params: dict[str, Any]) -> dict[str, Any]: - """Send request and receive response.""" - if not self._writer: - await self.connect() - assert self._writer - assert self._reader - - request = { - "jsonrpc": "2.0", - "id": str(id(params)), - "method": method, - "params": params, - } - content = json.dumps(request, ensure_ascii=False).encode("utf-8") - header = f"Content-Length: {len(content)}\r\n\r\n".encode() - - await self._writer.send_all(header + content) - - # Read response header - header_data = b"" - while b"\r\n\r\n" not in header_data: - chunk = await self._reader.receive_some(1024) - if not chunk: - raise RuntimeError("Connection closed") - header_data += chunk - - header_str = header_data.decode("utf-8") - content_length = 0 - for line in header_str.split("\r\n"): - if line.lower().startswith("content-length:"): - content_length = int(line.split(":")[1].strip()) - - # Read response body - body_data = b"" - while len(body_data) < content_length: - chunk = await self._reader.receive_some(content_length - len(body_data)) - if not chunk: - raise RuntimeError("Connection closed") - body_data += chunk - - response = json.loads(body_data.decode("utf-8")) - if "error" in response: - raise RuntimeError(f"JSON-RPC error: {response['error']}") - return response.get("result", {}) - - async def notify(self, method: str, params: dict[str, Any]) -> None: - """Send notification (no response expected).""" - if not self._writer: - await self.connect() - assert self._writer - - notification = { - "jsonrpc": "2.0", - "method": method, - "params": params, - } - content = json.dumps(notification, ensure_ascii=False).encode("utf-8") - header = f"Content-Length: {len(content)}\r\n\r\n".encode() - await self._writer.send_all(header + content) - - async def close(self) -> None: - """Close the socket connection.""" - if self._writer: - await self._writer.aclose() - self._writer = None - self._reader = None - - -class HttpTransport(Transport): - """HTTP/SSE transport for remote plugins.""" - - def __init__(self, url: str) -> None: - self._url = url - self._client: anyio.http.websockets.client.ClientSession | None = None - - async def connect(self) -> None: - """Initialize HTTP client.""" - # For now, use aiohttp-like interface via anyio - pass - - async def send(self, method: str, params: dict[str, Any]) -> dict[str, Any]: - """Send HTTP POST request.""" - request = { - "jsonrpc": "2.0", - "id": str(id(params)), - "method": method, - "params": params, - } - - async with anyio.open_http_connect(self._url) as (reader, writer): - body = json.dumps(request).encode("utf-8") - writer.write( - f"POST / HTTP/1.1\r\n" - f"Host: {self._url}\r\n" - f"Content-Type: application/json\r\n" - f"Content-Length: {len(body)}\r\n" - f"\r\n".encode() - ) - writer.write(body) - await writer.aclose() - - # Read response - response = await reader.read() - # Simple parsing - in reality would need proper HTTP parsing - return json.loads(response.decode("utf-8")).get("result", {}) - - async def notify(self, method: str, params: dict[str, Any]) -> None: - """Send notification via HTTP POST.""" - notification = { - "jsonrpc": "2.0", - "method": method, - "params": params, - } - body = json.dumps(notification).encode("utf-8") - async with anyio.open_http_connect(self._url) as (_reader, writer): - writer.write( - f"POST / HTTP/1.1\r\n" - f"Host: {self._url}\r\n" - f"Content-Type: application/json\r\n" - f"Content-Length: {len(body)}\r\n" - f"\r\n".encode() - ) - writer.write(body) - await writer.aclose() - - async def close(self) -> None: - """Close the transport.""" - if self._client: - await self._client.aclose() - self._client = None diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index d75fe236a..64a41dc10 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -820,6 +820,8 @@ class ProviderOpenAIOfficial(Provider): model = model or self.get_model() payloads = {**kwargs, "messages": context_query, "model": model} + # Filter out non-JSON-serializable objects (e.g. asyncio.Event from abort_signal) + payloads.pop("abort_signal", None) self._finally_convert_payload(payloads) diff --git a/astrbot/rust/_core.pyi b/astrbot/rust/_core.pyi index 9cfaa52d8..9baf0c088 100644 --- a/astrbot/rust/_core.pyi +++ b/astrbot/rust/_core.pyi @@ -1,6 +1,7 @@ from typing import Any -class AstrbotOrchestrator: +class PyOrchestrator: + def __init__(self) -> None: ... def start(self) -> None: ... def stop(self) -> None: ... def is_running(self) -> bool: ... @@ -8,89 +9,29 @@ class AstrbotOrchestrator: def unregister_star(self, name: str) -> None: ... def list_stars(self) -> list[str]: ... def record_activity(self) -> None: ... - def get_stats(self) -> dict[str, Any]: ... + def get_stats(self, py: Any) -> dict[str, Any]: ... def set_protocol_connected(self, protocol: str, connected: bool) -> None: ... - def get_protocol_status(self, protocol: str) -> dict[str, Any] | None: ... + def get_protocol_status( + self, protocol: str, py: Any + ) -> dict[str, Any] | None: ... -# ABP Plugin types - TODO: expose via PyO3 bindings - -class PluginLoadMode: - IN_PROCESS: PluginLoadMode - OUT_OF_PROCESS: PluginLoadMode - -class PluginTransport: - STDIO: PluginTransport - UNIX_SOCKET: PluginTransport - HTTP: PluginTransport - -class PluginConfig: - name: str - version: str - load_mode: PluginLoadMode - command: str | None - args: list[str] - env: dict[str, str] - transport: PluginTransport - url: str | None - -class PluginCapabilities: - tools: bool - handlers: bool - events: bool - resources: bool - -class PluginMetadata: - display_name: str | None - description: str | None - author: str | None - homepage: str | None - support_platforms: list[str] - astrbot_version: str | None - -class AbpClient: +class PyAbpClient: def __init__(self) -> None: ... - async def connect(self) -> None: ... - async def disconnect(self) -> None: ... def is_connected(self) -> bool: ... - def register_in_process_plugin(self, config: PluginConfig) -> None: ... - def register_out_of_process_plugin(self, config: PluginConfig) -> None: ... + def register_in_process_plugin(self, name: str, version: str) -> None: ... + def register_out_of_process_plugin( + self, + name: str, + version: str, + command: str | None = None, + transport: str | None = None, + ) -> None: ... def unregister_plugin(self, name: str) -> None: ... def list_plugins(self) -> list[str]: ... - def get_plugin_info(self, name: str) -> PluginInfo | None: ... - async def call_tool(self, plugin_name: str, tool_name: str, arguments: dict[str, Any]) -> ToolResult: ... - async def handle_event(self, plugin_name: str, event_type: str, event: dict[str, Any]) -> HandleEventResult: ... + def get_plugin_info(self, name: str, py: Any) -> dict[str, Any] | None: ... + def health_check(self, name: str) -> bool: ... -class PluginInfo: - name: str - version: str - load_mode: PluginLoadMode - capabilities: PluginCapabilities - metadata: PluginMetadata | None - tools_count: int - -class Tool: - name: str - description: str - input_schema: dict[str, Any] - -class ToolResult: - content: list[ToolContent] - -class ToolContent: - type: str - text: str | None - image: str | None - url: str | None - -class HandleEventResult: - handled: bool - results: list[MessageChainItem] - stop_propagation: bool - -class MessageChainItem: - type: str - text: str | None - -def get_orchestrator() -> AstrbotOrchestrator: ... -def get_abp_client() -> AbpClient: ... +# Module-level functions +def get_orchestrator() -> PyOrchestrator: ... +def get_abp_client() -> PyAbpClient: ... def cli(args: list[str]) -> None: ... diff --git a/dashboard/.eslintignore b/dashboard/.eslintignore deleted file mode 100644 index 3e7871a3a..000000000 --- a/dashboard/.eslintignore +++ /dev/null @@ -1,29 +0,0 @@ -# ESLint ignore file for AstrBot dashboard -# Skip dependency directories and build artifacts - -node_modules/ -dist/ -build/ -public/ -coverage/ -.vite/ -.cache/ -*.min.js -*.bundle.js -*.map - -# Dashboard-specific artifacts (when lint is run from repo root using --prefix) -dashboard/dist/ -dashboard/node_modules/ - -# Generated TypeScript declaration used by environment - can cause parser issues in some setups -env.d.ts - -# Scripts and tooling files (often use newer syntax or are run in node env) -scripts/**/*.mjs -scripts/**/*.cjs - -# Misc -*.log -.idea/ -.vscode/ diff --git a/dashboard/eslint.config.js b/dashboard/eslint.config.js index 48e1735e5..36f6244f6 100644 --- a/dashboard/eslint.config.js +++ b/dashboard/eslint.config.js @@ -1,83 +1,75 @@ -const vue = require('eslint-plugin-vue'); -const prettierPlugin = require('eslint-plugin-prettier'); -const configPrettier = require('eslint-config-prettier'); -const tseslint = require('typescript-eslint'); - -const { rules: vueRules } = vue; -const vue3Recommended = vue.configs['vue3-recommended']; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const prettierPlugin = require("eslint-plugin-prettier"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const configPrettier = require("eslint-config-prettier"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const vueTsConfig = require("@vue/eslint-config-typescript"); module.exports = [ { - ignores: ['dist/', 'build/', 'node_modules/', 'public/', 'dashboard/dist/', 'dashboard/node_modules/', 'env.d.ts', '.vite/', '.cache/'], + ignores: [ + "dist/", + "build/", + "node_modules/", + "public/", + "dashboard/dist/", + "dashboard/node_modules/", + "env.d.ts", + ".vite/", + ".cache/", + ], }, + ...vueTsConfig.createConfig(), { - files: ['**/*.vue'], - ...vue3Recommended, - languageOptions: { - parser: require('vue-eslint-parser'), - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - }, - }, - plugins: { - vue, - prettier: prettierPlugin, - ...tseslint.plugin, - }, - rules: { - ...vue3Recommended.rules, - ...tseslint.plugin.configs.recommended.rules, - 'vue/multi-word-component-names': 'off', - 'vue/html-self-closing': ['error', { html: { void: 'never', normal: 'always', component: 'always' }, svg: 'always', math: 'always' }], - 'vue/valid-v-slot': 'off', - 'vue/v-on-event-hyphenation': 'off', - 'vue/no-unused-components': 'off', - 'vue/no-unused-vars': 'off', - 'vue/require-default-prop': 'off', - 'vue/no-v-html': 'warn', - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }], - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - 'no-console': ['warn', { allow: ['warn', 'error', 'info'] }], - 'no-debugger': 'warn', - 'prettier/prettier': 'error', - }, - }, - { - files: ['**/*.ts', '**/*.tsx', 'src/**/*.ts', 'src/**/*.tsx'], - languageOptions: { - parser: tseslint.parser, - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - }, - }, plugins: { prettier: prettierPlugin, - ...tseslint.plugin, }, rules: { - ...tseslint.plugin.configs.recommended.rules, - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }], - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - 'no-console': ['warn', { allow: ['warn', 'error', 'info'] }], - 'no-debugger': 'warn', - 'prettier/prettier': 'error', + "vue/multi-word-component-names": "off", + "vue/html-self-closing": [ + "error", + { + html: { void: "never", normal: "always", component: "always" }, + svg: "always", + math: "always", + }, + ], + "vue/valid-v-slot": "off", + "vue/v-on-event-hyphenation": "off", + "vue/no-unused-components": "off", + "vue/no-unused-vars": "off", + "vue/require-default-prop": "off", + "vue/no-v-html": "warn", + "vue/block-lang": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-explicit-any": "off", + "no-console": ["warn", { allow: ["warn", "error", "info"] }], + "no-debugger": "warn", + "prettier/prettier": "error", }, }, { - files: ['scripts/**/*.mjs', 'scripts/**/*.cjs', '*.cjs'], + files: ["scripts/**/*.mjs", "scripts/**/*.cjs", "*.cjs"], languageOptions: { - parserOptions: { sourceType: 'module' }, - env: { node: true }, + parserOptions: { sourceType: "module" }, + globals: { node: true }, }, }, { - files: ['src/components/extension/**', 'src/components/extension/componentPanel/**'], + files: [ + "src/components/extension/**", + "src/components/extension/componentPanel/**", + ], rules: { - 'vue/valid-v-slot': 'off', + "vue/valid-v-slot": "off", }, }, configPrettier, diff --git a/dashboard/scripts/subset-mdi-font.mjs b/dashboard/scripts/subset-mdi-font.mjs index 358d467d5..85a416188 100644 --- a/dashboard/scripts/subset-mdi-font.mjs +++ b/dashboard/scripts/subset-mdi-font.mjs @@ -11,7 +11,15 @@ * Fallback: if any step fails, copies the original full @mdi/font CSS and fonts * so the build never breaks. */ -import { readFileSync, writeFileSync, copyFileSync, readdirSync, statSync, existsSync, mkdirSync } from "fs"; +import { + readFileSync, + writeFileSync, + copyFileSync, + readdirSync, + statSync, + existsSync, + mkdirSync, +} from "fs"; import { join, resolve, extname } from "path"; import { fileURLToPath } from "url"; @@ -19,115 +27,143 @@ import { fileURLToPath } from "url"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); const ROOT = resolve(__dirname, ".."); const SRC = join(ROOT, "src"); -const MDI_CSS_PATH = join(ROOT, "node_modules/@mdi/font/css/materialdesignicons.css"); -const MDI_TTF_PATH = join(ROOT, "node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"); -const MDI_WOFF2_PATH = join(ROOT, "node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"); -const MDI_WOFF_PATH = join(ROOT, "node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"); +const MDI_CSS_PATH = join( + ROOT, + "node_modules/@mdi/font/css/materialdesignicons.css", +); +const MDI_TTF_PATH = join( + ROOT, + "node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf", +); +const MDI_WOFF2_PATH = join( + ROOT, + "node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2", +); +const MDI_WOFF_PATH = join( + ROOT, + "node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff", +); const OUT_DIR = join(ROOT, "src/assets/mdi-subset"); // Utility classes that should not be treated as icon names const UTILITY_CLASSES = new Set([ - "mdi-set", "mdi-spin", "mdi-rotate-45", "mdi-rotate-90", "mdi-rotate-135", - "mdi-rotate-180", "mdi-rotate-225", "mdi-rotate-270", "mdi-rotate-315", - "mdi-flip-h", "mdi-flip-v", "mdi-light", "mdi-dark", "mdi-inactive", - "mdi-18px", "mdi-24px", "mdi-36px", "mdi-48px", "mdi-subset", + "mdi-set", + "mdi-spin", + "mdi-rotate-45", + "mdi-rotate-90", + "mdi-rotate-135", + "mdi-rotate-180", + "mdi-rotate-225", + "mdi-rotate-270", + "mdi-rotate-315", + "mdi-flip-h", + "mdi-flip-v", + "mdi-light", + "mdi-dark", + "mdi-inactive", + "mdi-18px", + "mdi-24px", + "mdi-36px", + "mdi-48px", + "mdi-subset", ]); // Icons used indirectly by Vuetify internals, so they won't appear in src/ static scans. export const REQUIRED_ICONS = new Set([ - "mdi-radiobox-blank", - "mdi-radiobox-marked", - "mdi-menu-down", - "mdi-menu-right", - "mdi-check-circle", - "mdi-information", - "mdi-alert-circle", - "mdi-close-circle", - "mdi-chevron-down", - "mdi-chevron-up", - "mdi-chevron-left", - "mdi-chevron-right", - "mdi-check", - "mdi-close", - "mdi-checkbox-marked", - "mdi-checkbox-blank-outline", - "mdi-minus-box", - "mdi-circle", - "mdi-arrow-up", - "mdi-arrow-down", - "mdi-menu", - "mdi-pencil", - "mdi-star-outline", - "mdi-star", - "mdi-star-half-full", - "mdi-cached", - "mdi-page-first", - "mdi-page-last", - "mdi-unfold-more-horizontal", - "mdi-paperclip", - "mdi-plus", - "mdi-minus", - "mdi-calendar", - "mdi-eyedropper", - "mdi-cloud-upload", + "mdi-radiobox-blank", + "mdi-radiobox-marked", + "mdi-menu-down", + "mdi-menu-right", + "mdi-check-circle", + "mdi-information", + "mdi-alert-circle", + "mdi-close-circle", + "mdi-chevron-down", + "mdi-chevron-up", + "mdi-chevron-left", + "mdi-chevron-right", + "mdi-check", + "mdi-close", + "mdi-checkbox-marked", + "mdi-checkbox-blank-outline", + "mdi-minus-box", + "mdi-circle", + "mdi-arrow-up", + "mdi-arrow-down", + "mdi-menu", + "mdi-pencil", + "mdi-star-outline", + "mdi-star", + "mdi-star-half-full", + "mdi-cached", + "mdi-page-first", + "mdi-page-last", + "mdi-unfold-more-horizontal", + "mdi-paperclip", + "mdi-plus", + "mdi-minus", + "mdi-calendar", + "mdi-eyedropper", + "mdi-cloud-upload", ]); // Regex to match individual icon class definitions in MDI CSS -export const ICON_CLASS_PATTERN = /\.(mdi-[a-z][a-z0-9-]*)::before\s*\{\s*content:\s*"\\([0-9A-Fa-f]+)"\s*;?\s*}/g; +export const ICON_CLASS_PATTERN = + /\.(mdi-[a-z][a-z0-9-]*)::before\s*\{\s*content:\s*"\\([0-9A-Fa-f]+)"\s*;?\s*}/g; // ── Helper functions ──────────────────────────────────────────────────────── /** Recursively collect files with given extensions, skipping node_modules. */ export function* collectFiles(dir, exts) { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory() && entry.name !== "node_modules") { - yield* collectFiles(full, exts); - } else if (exts.includes(extname(entry.name))) { - yield full; - } + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory() && entry.name !== "node_modules") { + yield* collectFiles(full, exts); + } else if (exts.includes(extname(entry.name))) { + yield full; } + } } /** Scan source files and return a Set of used mdi-* icon names. */ export function scanUsedIcons(sourceFiles) { - const iconPattern = /mdi-[a-z][a-z0-9-]*/g; - const usedIcons = new Set(REQUIRED_ICONS); - for (const file of sourceFiles) { - const content = readFileSync(file, "utf-8"); - for (const match of content.matchAll(iconPattern)) { - if (!UTILITY_CLASSES.has(match[0])) { - usedIcons.add(match[0]); - } - } + const iconPattern = /mdi-[a-z][a-z0-9-]*/g; + const usedIcons = new Set(REQUIRED_ICONS); + for (const file of sourceFiles) { + const content = readFileSync(file, "utf-8"); + for (const match of content.matchAll(iconPattern)) { + if (!UTILITY_CLASSES.has(match[0])) { + usedIcons.add(match[0]); + } } - return usedIcons; + } + return usedIcons; } /** Parse @mdi/font CSS and return a Map of icon-name → hex codepoint. */ export function parseIconCodepoints(mdiCSS) { - const iconMap = new Map(); - for (const match of mdiCSS.matchAll(ICON_CLASS_PATTERN)) { - iconMap.set(match[1], match[2]); - } - return iconMap; + const iconMap = new Map(); + for (const match of mdiCSS.matchAll(ICON_CLASS_PATTERN)) { + iconMap.set(match[1], match[2]); + } + return iconMap; } /** Resolve used icons against the codepoint map, returning resolved/missing/subsetChars. */ export function resolveUsedIcons(usedIcons, iconMap) { - const resolvedIcons = []; - const missingIcons = []; - const subsetChars = []; - for (const icon of usedIcons) { - const cp = iconMap.get(icon); - if (cp) { - resolvedIcons.push(icon); - subsetChars.push(String.fromCodePoint(parseInt(cp, 16))); - } else { - missingIcons.push(icon); - } + const resolvedIcons = []; + const missingIcons = []; + const subsetChars = []; + for (const icon of usedIcons) { + const cp = iconMap.get(icon); + if (cp) { + resolvedIcons.push(icon); + subsetChars.push(String.fromCodePoint(parseInt(cp, 16))); + } else { + missingIcons.push(icon); } - return { resolvedIcons, missingIcons, subsetChars }; + } + return { resolvedIcons, missingIcons, subsetChars }; } /** @@ -137,128 +173,170 @@ export function resolveUsedIcons(usedIcons, iconMap) { * relying on a fixed start marker, as it tolerates CSS reordering in future versions. */ export function extractUtilityCss(mdiCSS, iconClassPattern) { - let utilityCss = mdiCSS - .replace(iconClassPattern, "") // Remove icon definitions - .replace(/@font-face\s*\{[\s\S]*?}/g, "") // Remove @font-face - .replace(/\.mdi:before,\s*\.mdi-set\s*\{[\s\S]*?}/g, "") // Remove base rules - .replace(/\/\*# sourceMappingURL=.*\*\//, "") // Remove source map - .trim(); + let utilityCss = mdiCSS + .replace(iconClassPattern, "") // Remove icon definitions + .replace(/@font-face\s*\{[\s\S]*?}/g, "") // Remove @font-face + .replace(/\.mdi:before,\s*\.mdi-set\s*\{[\s\S]*?}/g, "") // Remove base rules + .replace(/\/\*# sourceMappingURL=.*\*\//, "") // Remove source map + .trim(); - // Clean up excess blank lines left after removals - utilityCss = utilityCss.replace(/(\r\n|\n){3,}/g, "\n\n"); + // Clean up excess blank lines left after removals + utilityCss = utilityCss.replace(/(\r\n|\n){3,}/g, "\n\n"); - return utilityCss; + return utilityCss; } /** Build a fallback CSS that rewrites font URLs to use subset filenames. */ function buildFallbackCss() { - const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8"); - return mdiCSS - // Rewrite woff/woff2 URLs to point at subset filenames - .replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(woff2?)\?[^"]*"\)/g, - (_, ext) => `url("./materialdesignicons-webfont-subset.${ext}")`) - // Remove legacy eot/ttf sources - .replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(eot|ttf)\?[^"]*"\)[^,]*/g, "") - // Clean up dangling commas/separators - .replace(/src:\s*,/g, "src:") - .replace(/,\s*;/g, ";"); + const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8"); + return ( + mdiCSS + // Rewrite woff/woff2 URLs to point at subset filenames + .replace( + /url\("\.\.\/fonts\/materialdesignicons-webfont\.(woff2?)\?[^"]*"\)/g, + (_, ext) => `url("./materialdesignicons-webfont-subset.${ext}")`, + ) + // Remove legacy eot/ttf sources + .replace( + /url\("\.\.\/fonts\/materialdesignicons-webfont\.(eot|ttf)\?[^"]*"\)[^,]*/g, + "", + ) + // Clean up dangling commas/separators + .replace(/src:\s*,/g, "src:") + .replace(/,\s*;/g, ";") + ); } // ── Fallback: copy original full MDI font if subsetting fails ─────────────── function fallbackToFullFont(reason) { - console.warn(`\n⚠️ Subsetting failed: ${reason}`); - console.warn(`⚠️ Falling back to full @mdi/font (build will not break)\n`); + console.warn(`\n⚠️ Subsetting failed: ${reason}`); + console.warn(`⚠️ Falling back to full @mdi/font (build will not break)\n`); - // Copy original font files - if (existsSync(MDI_WOFF2_PATH)) { - copyFileSync(MDI_WOFF2_PATH, join(OUT_DIR, "materialdesignicons-webfont-subset.woff2")); - } - if (existsSync(MDI_WOFF_PATH)) { - copyFileSync(MDI_WOFF_PATH, join(OUT_DIR, "materialdesignicons-webfont-subset.woff")); - } + // Copy original font files + if (existsSync(MDI_WOFF2_PATH)) { + copyFileSync( + MDI_WOFF2_PATH, + join(OUT_DIR, "materialdesignicons-webfont-subset.woff2"), + ); + } + if (existsSync(MDI_WOFF_PATH)) { + copyFileSync( + MDI_WOFF_PATH, + join(OUT_DIR, "materialdesignicons-webfont-subset.woff"), + ); + } - writeFileSync(join(OUT_DIR, "materialdesignicons-subset.css"), buildFallbackCss()); + writeFileSync( + join(OUT_DIR, "materialdesignicons-subset.css"), + buildFallbackCss(), + ); - const size = existsSync(MDI_WOFF2_PATH) ? statSync(MDI_WOFF2_PATH).size : 0; - console.warn(`⚠️ Fallback complete: using full font (${(size / 1024).toFixed(1)} KB woff2)`); + const size = existsSync(MDI_WOFF2_PATH) ? statSync(MDI_WOFF2_PATH).size : 0; + console.warn( + `⚠️ Fallback complete: using full font (${(size / 1024).toFixed(1)} KB woff2)`, + ); } // ── Exported entry point ──────────────────────────────────────────────────── export async function runMdiSubset() { - mkdirSync(OUT_DIR, { recursive: true }); + mkdirSync(OUT_DIR, { recursive: true }); + try { + // Pre-checks + if (!existsSync(MDI_CSS_PATH)) { + throw new Error( + `@mdi/font CSS not found at ${MDI_CSS_PATH}. Run 'pnpm install' first.`, + ); + } + if (!existsSync(MDI_TTF_PATH)) { + throw new Error( + `@mdi/font TTF not found at ${MDI_TTF_PATH}. Run 'pnpm install' first.`, + ); + } + + // Dynamic import subset-font (may not be installed in all environments) + let subsetFont; try { - // Pre-checks - if (!existsSync(MDI_CSS_PATH)) { - throw new Error(`@mdi/font CSS not found at ${MDI_CSS_PATH}. Run 'pnpm install' first.`); - } - if (!existsSync(MDI_TTF_PATH)) { - throw new Error(`@mdi/font TTF not found at ${MDI_TTF_PATH}. Run 'pnpm install' first.`); - } + subsetFont = (await import("subset-font")).default; + } catch (e) { + throw new Error( + `subset-font package not available: ${e.message}. Run 'pnpm install' first.`, + ); + } - // Dynamic import subset-font (may not be installed in all environments) - let subsetFont; - try { - subsetFont = (await import("subset-font")).default; - } catch (e) { - throw new Error(`subset-font package not available: ${e.message}. Run 'pnpm install' first.`); - } + // Step 1: Scan source files for mdi-* icon names + const sourceFiles = collectFiles(SRC, [".vue", ".ts", ".js"]); + const usedIcons = scanUsedIcons(sourceFiles); + if (usedIcons.size === 0) { + throw new Error( + "No mdi-* icons found in source files. Something is wrong with scanning.", + ); + } + console.log( + `✅ Found ${usedIcons.size} unique mdi-* icons in source files`, + ); - // Step 1: Scan source files for mdi-* icon names - const sourceFiles = collectFiles(SRC, [".vue", ".ts", ".js"]); - const usedIcons = scanUsedIcons(sourceFiles); - if (usedIcons.size === 0) { - throw new Error("No mdi-* icons found in source files. Something is wrong with scanning."); - } - console.log(`✅ Found ${usedIcons.size} unique mdi-* icons in source files`); + // Step 2: Parse @mdi/font CSS to get codepoints for each icon + const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8"); + const iconMap = parseIconCodepoints(mdiCSS); + if (iconMap.size === 0) { + throw new Error( + "Could not parse any icon definitions from @mdi/font CSS. Format may have changed.", + ); + } + console.log(`📦 MDI font CSS contains ${iconMap.size} icon definitions`); - // Step 2: Parse @mdi/font CSS to get codepoints for each icon - const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8"); - const iconMap = parseIconCodepoints(mdiCSS); - if (iconMap.size === 0) { - throw new Error("Could not parse any icon definitions from @mdi/font CSS. Format may have changed."); - } - console.log(`📦 MDI font CSS contains ${iconMap.size} icon definitions`); + // Step 3: Resolve codepoints for used icons + const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons( + usedIcons, + iconMap, + ); + if (missingIcons.length > 0) { + console.warn( + `⚠️ ${missingIcons.length} icons not found in MDI CSS:`, + missingIcons.join(", "), + ); + } + if (resolvedIcons.length === 0) { + throw new Error( + "No icon codepoints could be resolved. Icon name format may have changed.", + ); + } + console.log( + `🔍 Resolved ${resolvedIcons.length} codepoints for subsetting`, + ); - // Step 3: Resolve codepoints for used icons - const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons(usedIcons, iconMap); - if (missingIcons.length > 0) { - console.warn(`⚠️ ${missingIcons.length} icons not found in MDI CSS:`, missingIcons.join(", ")); - } - if (resolvedIcons.length === 0) { - throw new Error("No icon codepoints could be resolved. Icon name format may have changed."); - } - console.log(`🔍 Resolved ${resolvedIcons.length} codepoints for subsetting`); + // Add space character + subsetChars.push(" "); + const subsetText = subsetChars.join(""); - // Add space character - subsetChars.push(" "); - const subsetText = subsetChars.join(""); + // Step 4: Subset font with subset-font (pure JS/WASM) + const fontBuffer = readFileSync(MDI_TTF_PATH); - // Step 4: Subset font with subset-font (pure JS/WASM) - const fontBuffer = readFileSync(MDI_TTF_PATH); + console.log(`🔧 Subsetting font to woff2...`); + const woff2Buffer = await subsetFont(fontBuffer, subsetText, { + targetFormat: "woff2", + }); - console.log(`🔧 Subsetting font to woff2...`); - const woff2Buffer = await subsetFont(fontBuffer, subsetText, { - targetFormat: "woff2", - }); + console.log(`🔧 Subsetting font to woff...`); + const woffBuffer = await subsetFont(fontBuffer, subsetText, { + targetFormat: "woff", + }); - console.log(`🔧 Subsetting font to woff...`); - const woffBuffer = await subsetFont(fontBuffer, subsetText, { - targetFormat: "woff", - }); + if (woff2Buffer.length === 0 || woffBuffer.length === 0) { + throw new Error( + "subset-font produced empty output. Font file may be corrupted.", + ); + } - if (woff2Buffer.length === 0 || woffBuffer.length === 0) { - throw new Error("subset-font produced empty output. Font file may be corrupted."); - } + const outWoff2 = join(OUT_DIR, "materialdesignicons-webfont-subset.woff2"); + const outWoff = join(OUT_DIR, "materialdesignicons-webfont-subset.woff"); + writeFileSync(outWoff2, woff2Buffer); + writeFileSync(outWoff, woffBuffer); - const outWoff2 = join(OUT_DIR, "materialdesignicons-webfont-subset.woff2"); - const outWoff = join(OUT_DIR, "materialdesignicons-webfont-subset.woff"); - writeFileSync(outWoff2, woff2Buffer); - writeFileSync(outWoff, woffBuffer); - - // Step 5: Generate subset CSS - let css = `/* Auto-generated MDI subset – ${resolvedIcons.length} icons */ + // Step 5: Generate subset CSS + let css = `/* Auto-generated MDI subset – ${resolvedIcons.length} icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -282,49 +360,57 @@ export async function runMdiSubset() { `; - for (const icon of resolvedIcons.sort()) { - const cp = iconMap.get(icon); - css += `.${icon}::before {\n content: "\\${cp}";\n}\n\n`; - } - - const utilityCss = extractUtilityCss(mdiCSS, ICON_CLASS_PATTERN); - if (utilityCss) { - css += `/* Utility classes (extracted from @mdi/font) */\n${utilityCss}\n`; - } else { - console.warn("⚠️ Could not find MDI utility classes in original CSS, skipping"); - } - - const outCSS = join(OUT_DIR, "materialdesignicons-subset.css"); - writeFileSync(outCSS, css); - - // Report - const origSize = statSync(MDI_TTF_PATH).size; - const subsetWoff2Size = woff2Buffer.length; - console.log(`\n📊 Results:`); - console.log(` Original TTF font: ${(origSize / 1024).toFixed(1)} KB`); - console.log(` Subset WOFF2: ${(subsetWoff2Size / 1024).toFixed(1)} KB`); - console.log(` Reduction: ${((1 - subsetWoff2Size / origSize) * 100).toFixed(1)}%`); - console.log(` Icons included: ${resolvedIcons.length}`); - console.log(` CSS file: ${outCSS}`); - console.log(`\n✅ MDI font subset generated successfully!`); - - } catch (err) { - // Fallback: any failure → use original full font so build never breaks - try { - fallbackToFullFont(err.message); - } catch (fallbackErr) { - console.error(`❌ Fallback also failed: ${fallbackErr.message}`); - console.error(`❌ Please ensure @mdi/font is installed: pnpm install`); - throw fallbackErr; - } + for (const icon of resolvedIcons.sort()) { + const cp = iconMap.get(icon); + css += `.${icon}::before {\n content: "\\${cp}";\n}\n\n`; } + + const utilityCss = extractUtilityCss(mdiCSS, ICON_CLASS_PATTERN); + if (utilityCss) { + css += `/* Utility classes (extracted from @mdi/font) */\n${utilityCss}\n`; + } else { + console.warn( + "⚠️ Could not find MDI utility classes in original CSS, skipping", + ); + } + + const outCSS = join(OUT_DIR, "materialdesignicons-subset.css"); + writeFileSync(outCSS, css); + + // Report + const origSize = statSync(MDI_TTF_PATH).size; + const subsetWoff2Size = woff2Buffer.length; + console.log(`\n📊 Results:`); + console.log(` Original TTF font: ${(origSize / 1024).toFixed(1)} KB`); + console.log( + ` Subset WOFF2: ${(subsetWoff2Size / 1024).toFixed(1)} KB`, + ); + console.log( + ` Reduction: ${((1 - subsetWoff2Size / origSize) * 100).toFixed(1)}%`, + ); + console.log(` Icons included: ${resolvedIcons.length}`); + console.log(` CSS file: ${outCSS}`); + console.log(`\n✅ MDI font subset generated successfully!`); + } catch (err) { + // Fallback: any failure → use original full font so build never breaks + try { + fallbackToFullFont(err.message); + } catch (fallbackErr) { + console.error(`❌ Fallback also failed: ${fallbackErr.message}`); + console.error(`❌ Please ensure @mdi/font is installed: pnpm install`); + throw fallbackErr; + } + } } // ── CLI entry point: allows running directly via `node scripts/subset-mdi-font.mjs` ── -if (import.meta.url.startsWith('file:') && process.argv[1] === fileURLToPath(import.meta.url)) { - runMdiSubset().catch(err => { - console.error(err); - process.exit(1); - }); +if ( + import.meta.url.startsWith("file:") && + process.argv[1] === fileURLToPath(import.meta.url) +) { + runMdiSubset().catch((err) => { + console.error(err); + process.exit(1); + }); } diff --git a/dashboard/src/App.vue b/dashboard/src/App.vue index 68eeb7db9..ab58fa471 100644 --- a/dashboard/src/App.vue +++ b/dashboard/src/App.vue @@ -13,27 +13,19 @@ close-on-back > {{ toastStore.current.message }} - - diff --git a/dashboard/src/components/ConfirmDialog.vue b/dashboard/src/components/ConfirmDialog.vue index 32695fa06..3a30a54e0 100644 --- a/dashboard/src/components/ConfirmDialog.vue +++ b/dashboard/src/components/ConfirmDialog.vue @@ -1,8 +1,5 @@ - diff --git a/dashboard/src/components/chat/ChatInput.vue b/dashboard/src/components/chat/ChatInput.vue index 6f34da79c..5371300b8 100644 --- a/dashboard/src/components/chat/ChatInput.vue +++ b/dashboard/src/components/chat/ChatInput.vue @@ -15,43 +15,25 @@ borderRadius: '24px', boxShadow: isDark ? 'none' : '0px 2px 2px rgba(0, 0, 0, 0.1)', backgroundColor: isDark ? '#2d2d2d' : 'transparent', - position: 'relative' + position: 'relative', }" > -
+
- - mdi-cloud-upload - - {{ tm('input.dropToUpload') }} + mdi-cloud-upload + {{ tm("input.dropToUpload") }}
- -
+ +
- - mdi-reply - - "{{ props.replyTo.selectedText }}" + mdi-reply + "{{ props.replyTo.selectedText }}"
-
+
- {{ tm('input.upload') }} + {{ tm("input.upload") }} @@ -134,7 +142,11 @@ /> - {{ enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled') }} + {{ + enableStreaming + ? tm("streaming.enabled") + : tm("streaming.disabled") + }} @@ -145,14 +157,22 @@ ref="providerModelMenuRef" />
-
+
+ /> - - {{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }} + + {{ + isRecording ? tm("voice.speaking") : tm("voice.startRecording") + }} - - - {{ tm('input.stopGenerating') }} + + + {{ tm("input.stopGenerating") }}
- +
-
- - - {{ tm('voice.recording') }} +
+ + + {{ tm("voice.recording") }} - - + + {{ file.original_name }} diff --git a/dashboard/src/components/chat/ConfigSelector.vue b/dashboard/src/components/chat/ConfigSelector.vue index e514501cd..63f209688 100644 --- a/dashboard/src/components/chat/ConfigSelector.vue +++ b/dashboard/src/components/chat/ConfigSelector.vue @@ -7,13 +7,10 @@ @click="openDialog" > - {{ tm('config.title') }} + {{ tm("config.title") }} {{ selectedConfigLabel }} @@ -27,37 +24,20 @@ - + 选择配置文件 - + mdi-close -
- +
+
- + @@ -89,12 +66,7 @@ - - 取消 - + 取消 diff --git a/dashboard/src/components/chat/ConversationSidebar.vue b/dashboard/src/components/chat/ConversationSidebar.vue index cab30e4d0..ea01031e1 100644 --- a/dashboard/src/components/chat/ConversationSidebar.vue +++ b/dashboard/src/components/chat/ConversationSidebar.vue @@ -1,17 +1,19 @@ - {{ t('core.common.language') }} + {{ + t("core.common.language") + }} @@ -273,19 +265,18 @@ - + - + - + - {{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }} + {{ + isDark ? tm("modes.lightMode") : tm("modes.darkMode") + }} @@ -329,13 +321,13 @@ - {{ tm('transport.title') }} + {{ tm("transport.title") }} @@ -344,19 +336,18 @@ - + mdi-keyboard-outline - {{ tm('shortcuts.sendKey.title') }} + {{ + tm("shortcuts.sendKey.title") + }} @@ -401,19 +394,18 @@ - + - {{ chatboxMode ? tm('actions.exitFullscreen') : tm('actions.fullscreen') }} + {{ + chatboxMode + ? tm("actions.exitFullscreen") + : tm("actions.fullscreen") + }} @@ -443,7 +441,9 @@ - {{ tm('actions.providerConfig') }} + {{ + tm("actions.providerConfig") + }}
@@ -454,54 +454,54 @@ diff --git a/dashboard/src/components/chat/LiveMode.vue b/dashboard/src/components/chat/LiveMode.vue index 1bab0b978..c7ba8f78c 100644 --- a/dashboard/src/components/chat/LiveMode.vue +++ b/dashboard/src/components/chat/LiveMode.vue @@ -1,12 +1,7 @@ - diff --git a/dashboard/src/components/chat/ProjectList.vue b/dashboard/src/components/chat/ProjectList.vue index 793e317d7..608f33b6c 100644 --- a/dashboard/src/components/chat/ProjectList.vue +++ b/dashboard/src/components/chat/ProjectList.vue @@ -1,7 +1,7 @@ diff --git a/dashboard/src/components/chat/ProjectView.vue b/dashboard/src/components/chat/ProjectView.vue index d9c0b02c2..727dfb1b1 100644 --- a/dashboard/src/components/chat/ProjectView.vue +++ b/dashboard/src/components/chat/ProjectView.vue @@ -2,15 +2,12 @@
- {{ project?.emoji || '📁' }} + {{ project?.emoji || "📁" }}

{{ project?.title }}

-

+

{{ project.description }}

@@ -19,10 +16,7 @@
- + - {{ session.display_name || tm('conversation.newConversation') }} + {{ session.display_name || tm("conversation.newConversation") }} {{ formatDate(session.updated_at) }} @@ -44,7 +38,13 @@ size="x-small" variant="text" class="edit-session-btn" - @click.stop="$emit('editSessionTitle', session.session_id, session.display_name ?? '')" + @click.stop=" + $emit( + 'editSessionTitle', + session.session_id, + session.display_name ?? '', + ) + " /> -
+
-

{{ tm('project.noSessions') }}

+

{{ tm("project.noSessions") }}

diff --git a/dashboard/src/components/chat/ProviderConfigDialog.vue b/dashboard/src/components/chat/ProviderConfigDialog.vue index 2d2c7efe0..16d54b1ba 100644 --- a/dashboard/src/components/chat/ProviderConfigDialog.vue +++ b/dashboard/src/components/chat/ProviderConfigDialog.vue @@ -11,13 +11,9 @@ >
- {{ tm('title') }} + {{ tm("title") }}
- + mdi-close
@@ -25,7 +21,9 @@
-
+
@@ -68,7 +73,7 @@ {{ selectedProviderSource.id }}
- {{ selectedProviderSource.api_base || 'N/A' }} + {{ selectedProviderSource.api_base || "N/A" }}
- {{ tm('providerSources.save') }} + {{ tm("providerSources.save") }}
@@ -95,16 +100,12 @@
- - + + - {{ tm('providerSources.advancedConfig') }} + {{ + tm("providerSources.advancedConfig") + }}
- + mdi-cursor-default-click

- {{ tm('providerSources.selectHint') }} + {{ tm("providerSources.selectHint") }}

@@ -164,10 +162,7 @@
- + - + 取消 - - 添加 - + 添加 - + - 不建议修改 ID,可能会导致指向该模型的相关配置(如默认模型、插件相关配置等)失效。 + 不建议修改 + ID,可能会导致指向该模型的相关配置(如默认模型、插件相关配置等)失效。 - {{ tm('dialogs.config.cancel') }} + {{ tm("dialogs.config.cancel") }}
- {{ tm('dialogs.config.save') }} + {{ tm("dialogs.config.save") }} @@ -243,47 +230,47 @@ - diff --git a/dashboard/src/components/chat/StandaloneChat.vue b/dashboard/src/components/chat/StandaloneChat.vue index 0ee9cdc37..b1dd2aa93 100644 --- a/dashboard/src/components/chat/StandaloneChat.vue +++ b/dashboard/src/components/chat/StandaloneChat.vue @@ -1,9 +1,5 @@ - diff --git a/dashboard/src/components/chat/message_list_comps/ReasoningBlock.vue b/dashboard/src/components/chat/message_list_comps/ReasoningBlock.vue index d90f9190a..faba3f613 100644 --- a/dashboard/src/components/chat/message_list_comps/ReasoningBlock.vue +++ b/dashboard/src/components/chat/message_list_comps/ReasoningBlock.vue @@ -1,12 +1,6 @@ - diff --git a/dashboard/src/components/chat/message_list_comps/RefNode.vue b/dashboard/src/components/chat/message_list_comps/RefNode.vue index 45a83a6ba..1c0da43e4 100644 --- a/dashboard/src/components/chat/message_list_comps/RefNode.vue +++ b/dashboard/src/components/chat/message_list_comps/RefNode.vue @@ -9,84 +9,83 @@ target="_blank" clickable > - - mdi-link-variant - + mdi-link-variant {{ domain }}
- {{ 'site' }} + {{ "site" }} - diff --git a/dashboard/src/components/chat/message_list_comps/RefsSidebar.vue b/dashboard/src/components/chat/message_list_comps/RefsSidebar.vue index c263b0fa1..118c797d5 100644 --- a/dashboard/src/components/chat/message_list_comps/RefsSidebar.vue +++ b/dashboard/src/components/chat/message_list_comps/RefsSidebar.vue @@ -1,19 +1,11 @@ - diff --git a/dashboard/src/components/chat/message_list_comps/ToolCallCard.vue b/dashboard/src/components/chat/message_list_comps/ToolCallCard.vue index fe401419b..afea07b0d 100644 --- a/dashboard/src/components/chat/message_list_comps/ToolCallCard.vue +++ b/dashboard/src/components/chat/message_list_comps/ToolCallCard.vue @@ -1,11 +1,15 @@ - diff --git a/dashboard/src/components/chat/message_list_comps/ToolCallItem.vue b/dashboard/src/components/chat/message_list_comps/ToolCallItem.vue index 9f2fc6168..d3477fdbd 100644 --- a/dashboard/src/components/chat/message_list_comps/ToolCallItem.vue +++ b/dashboard/src/components/chat/message_list_comps/ToolCallItem.vue @@ -8,10 +8,7 @@ @keydown.enter="toggleExpanded" @keydown.space.prevent="toggleExpanded" > - +
- diff --git a/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue b/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue index 3c55f8c79..481e7626a 100644 --- a/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue +++ b/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue @@ -13,7 +13,7 @@ :value="section.key" style="font-weight: 1000; font-size: 15px" > - {{ tm(section.value['name']) }} + {{ tm(section.value["name"]) }} -
- {{ tm('help.helpPrefix') }} - {{ tm('help.documentation') }} - {{ tm('help.helpMiddle') }} + {{ tm("help.helpPrefix") }} + {{ + tm("help.documentation") + }} + {{ tm("help.helpMiddle") }} {{ tm('help.support') }}{{ tm('help.helpSuffix') }} + >{{ tm("help.support") }}{{ tm("help.helpSuffix") }}
- - - {{ tm('search.noResult') }} + + + {{ tm("search.noResult") }} - - diff --git a/dashboard/src/components/extension/componentPanel/components/DetailsDialog.vue b/dashboard/src/components/extension/componentPanel/components/DetailsDialog.vue index 247031551..75cac4393 100644 --- a/dashboard/src/components/extension/componentPanel/components/DetailsDialog.vue +++ b/dashboard/src/components/extension/componentPanel/components/DetailsDialog.vue @@ -1,9 +1,9 @@ @@ -53,13 +69,13 @@ const getPermissionLabel = (permission: string): string => { > - {{ tm('dialogs.details.title') }} + {{ tm("dialogs.details.title") }} - {{ tm('dialogs.details.type') }} + {{ tm("dialogs.details.type") }} { size="small" variant="tonal" > - + {{ getTypeInfo(command.type).icon }} {{ getTypeInfo(command.type).text }} @@ -79,37 +92,49 @@ const getPermissionLabel = (permission: string): string => { - {{ tm('dialogs.details.handler') }} + {{ tm("dialogs.details.handler") }} - {{ command.handler_name }} + {{ command.handler_name }} - {{ tm('dialogs.details.module') }} + {{ tm("dialogs.details.module") }} - {{ command.module_path }} + {{ command.module_path }} - {{ tm('dialogs.details.originalCommand') }} + {{ tm("dialogs.details.originalCommand") }} - {{ command.original_command }} + {{ command.original_command }} - {{ tm('dialogs.details.effectiveCommand') }} + {{ tm("dialogs.details.effectiveCommand") }} - {{ command.effective_command }} + {{ + command.effective_command + }} - {{ tm('dialogs.details.parentGroup') }} + {{ tm("dialogs.details.parentGroup") }} - {{ command.parent_signature }} + {{ command.parent_signature }} - {{ tm('dialogs.details.aliases') }} + {{ tm("dialogs.details.aliases") }} { - + - {{ tm('dialogs.details.subCommands') }} + {{ tm("dialogs.details.subCommands") }}
- @@ -141,7 +168,7 @@ const getPermissionLabel = (permission: string): string => { - {{ tm('dialogs.details.permission') }} + {{ tm("dialogs.details.permission") }} { - {{ tm('dialogs.details.conflictStatus') }} + {{ tm("dialogs.details.conflictStatus") }} - - {{ tm('status.conflict') }} + + {{ tm("status.conflict") }} @@ -174,7 +198,7 @@ const getPermissionLabel = (permission: string): string => { variant="text" @click="emit('update:show', false)" > - {{ t('core.actions.close') }} + {{ t("core.actions.close") }} diff --git a/dashboard/src/components/extension/componentPanel/components/RenameDialog.vue b/dashboard/src/components/extension/componentPanel/components/RenameDialog.vue index 39aa39544..009b0d914 100644 --- a/dashboard/src/components/extension/componentPanel/components/RenameDialog.vue +++ b/dashboard/src/components/extension/componentPanel/components/RenameDialog.vue @@ -1,9 +1,9 @@ @@ -105,7 +142,11 @@ const isInternal = (tool: ToolItem) => tool.source === 'internal'; class="font-weight-medium" :variant="item.active ? 'flat' : 'outlined'" > - {{ item.active ? tmCommand('status.enabled') : tmCommand('status.disabled') }} + {{ + item.active + ? tmCommand("status.enabled") + : tmCommand("status.disabled") + }} @@ -119,73 +160,54 @@ const isInternal = (tool: ToolItem) => tool.source === 'internal'; inset @update:model-value="emit('toggle-tool', item)" /> - — + — diff --git a/dashboard/src/components/folder/BaseFolderTreeNode.vue b/dashboard/src/components/folder/BaseFolderTreeNode.vue index 9319900e2..04d109ab6 100644 --- a/dashboard/src/components/folder/BaseFolderTreeNode.vue +++ b/dashboard/src/components/folder/BaseFolderTreeNode.vue @@ -21,15 +21,12 @@ @click.stop="toggleExpand" > - {{ isExpanded ? 'mdi-chevron-down' : 'mdi-chevron-right' }} + {{ isExpanded ? "mdi-chevron-down" : "mdi-chevron-right" }} -
+
- {{ isExpanded ? 'mdi-folder-open' : 'mdi-folder' }} + {{ isExpanded ? "mdi-folder-open" : "mdi-folder" }} @@ -61,121 +58,133 @@ diff --git a/dashboard/src/components/folder/BaseMoveTargetNode.vue b/dashboard/src/components/folder/BaseMoveTargetNode.vue index 25bea3be7..5b25ecf18 100644 --- a/dashboard/src/components/folder/BaseMoveTargetNode.vue +++ b/dashboard/src/components/folder/BaseMoveTargetNode.vue @@ -19,15 +19,20 @@ @click.stop="toggleExpand" > - {{ isExpanded ? 'mdi-chevron-down' : 'mdi-chevron-right' }} + {{ isExpanded ? "mdi-chevron-down" : "mdi-chevron-right" }} -
- - {{ isExpanded ? 'mdi-folder-open' : 'mdi-folder' }} +
+ + {{ isExpanded ? "mdi-folder-open" : "mdi-folder" }} @@ -53,66 +58,66 @@ diff --git a/dashboard/src/components/folder/BaseMoveToFolderDialog.vue b/dashboard/src/components/folder/BaseMoveToFolderDialog.vue index 8e8344969..856bf2ca1 100644 --- a/dashboard/src/components/folder/BaseMoveToFolderDialog.vue +++ b/dashboard/src/components/folder/BaseMoveToFolderDialog.vue @@ -1,14 +1,8 @@ - @@ -629,4 +654,3 @@ function getTemplateTitle(template, templateKey) { opacity: 0.8; } - diff --git a/dashboard/src/components/shared/PersonaQuickPreview.vue b/dashboard/src/components/shared/PersonaQuickPreview.vue index 749f7f11c..37b6909c2 100644 --- a/dashboard/src/components/shared/PersonaQuickPreview.vue +++ b/dashboard/src/components/shared/PersonaQuickPreview.vue @@ -1,13 +1,10 @@ - diff --git a/dashboard/src/components/shared/QrCodeViewer.vue b/dashboard/src/components/shared/QrCodeViewer.vue index 6d1252ae8..c47213730 100644 --- a/dashboard/src/components/shared/QrCodeViewer.vue +++ b/dashboard/src/components/shared/QrCodeViewer.vue @@ -1,22 +1,14 @@ - -
- +
+
- - {{ tm('buttons.cancel') }} + + {{ tm("buttons.cancel") }} - {{ tm('buttons.move') }} + {{ tm("buttons.move") }} @@ -84,152 +67,156 @@ diff --git a/dashboard/src/views/persona/index.ts b/dashboard/src/views/persona/index.ts index 322155b93..15322f008 100644 --- a/dashboard/src/views/persona/index.ts +++ b/dashboard/src/views/persona/index.ts @@ -1,23 +1,23 @@ /** * Persona 管理相关组件 - * + * * 这些组件使用了 dashboard/src/components/folder 下的通用文件夹组件 * 通过包装器模式将 personaStore 的状态和方法连接到通用组件 */ // 主组件 -export { default as PersonaManager } from './PersonaManager.vue'; +export { default as PersonaManager } from "./PersonaManager.vue"; // 文件夹相关组件 -export { default as FolderTree } from './FolderTree.vue'; -export { default as FolderTreeNode } from './FolderTreeNode.vue'; -export { default as FolderBreadcrumb } from './FolderBreadcrumb.vue'; -export { default as FolderCard } from './FolderCard.vue'; +export { default as FolderTree } from "./FolderTree.vue"; +export { default as FolderTreeNode } from "./FolderTreeNode.vue"; +export { default as FolderBreadcrumb } from "./FolderBreadcrumb.vue"; +export { default as FolderCard } from "./FolderCard.vue"; // 对话框组件 -export { default as CreateFolderDialog } from './CreateFolderDialog.vue'; -export { default as MoveToFolderDialog } from './MoveToFolderDialog.vue'; -export { default as MoveTargetNode } from './MoveTargetNode.vue'; +export { default as CreateFolderDialog } from "./CreateFolderDialog.vue"; +export { default as MoveToFolderDialog } from "./MoveToFolderDialog.vue"; +export { default as MoveTargetNode } from "./MoveTargetNode.vue"; // Persona 相关组件 -export { default as PersonaCard } from './PersonaCard.vue'; +export { default as PersonaCard } from "./PersonaCard.vue"; diff --git a/dashboard/tests/hashRouteTabs.test.mjs b/dashboard/tests/hashRouteTabs.test.mjs index 2aefd679c..dd4dad8c7 100644 --- a/dashboard/tests/hashRouteTabs.test.mjs +++ b/dashboard/tests/hashRouteTabs.test.mjs @@ -1,101 +1,104 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; +import test from "node:test"; +import assert from "node:assert/strict"; -import * as hashRouteTabs from '../src/utils/hashRouteTabs.mjs'; -import { EXTENSION_ROUTE_NAME } from '../src/router/routeConstants.mjs'; +import * as hashRouteTabs from "../src/utils/hashRouteTabs.mjs"; +import { EXTENSION_ROUTE_NAME } from "../src/router/routeConstants.mjs"; const { createTabRouteLocation, getValidHashTab } = hashRouteTabs; -test('getValidHashTab returns the tab name for a valid route hash', () => { - const validTabs = ['installed', 'market', 'mcp']; +test("getValidHashTab returns the tab name for a valid route hash", () => { + const validTabs = ["installed", "market", "mcp"]; - assert.equal(getValidHashTab('#market', validTabs), 'market'); + assert.equal(getValidHashTab("#market", validTabs), "market"); }); -test('getValidHashTab rejects empty and unknown hashes', () => { - const validTabs = ['installed', 'market', 'mcp']; +test("getValidHashTab rejects empty and unknown hashes", () => { + const validTabs = ["installed", "market", "mcp"]; - assert.equal(getValidHashTab('', validTabs), null); - assert.equal(getValidHashTab('#unknown', validTabs), null); + assert.equal(getValidHashTab("", validTabs), null); + assert.equal(getValidHashTab("#unknown", validTabs), null); }); -test('getValidHashTab uses the last hash segment when multiple hashes are present', () => { - const validTabs = ['installed', 'market', 'mcp']; +test("getValidHashTab uses the last hash segment when multiple hashes are present", () => { + const validTabs = ["installed", "market", "mcp"]; - assert.equal(getValidHashTab('#/extension#foo#installed', validTabs), 'installed'); + assert.equal( + getValidHashTab("#/extension#foo#installed", validTabs), + "installed", + ); }); -test('createTabRouteLocation preserves the current path and query', () => { - const query = { open_config: 'sample-plugin', page: '2' }; +test("createTabRouteLocation preserves the current path and query", () => { + const query = { open_config: "sample-plugin", page: "2" }; const location = createTabRouteLocation( { - path: '/extension', + path: "/extension", query, }, - 'market', + "market", ); assert.deepEqual(location, { - path: '/extension', - query: { open_config: 'sample-plugin', page: '2' }, - hash: '#market', + path: "/extension", + query: { open_config: "sample-plugin", page: "2" }, + hash: "#market", }); assert.notEqual(location.query, query); }); -test('createTabRouteLocation falls back to the extension route name', () => { - const location = createTabRouteLocation(undefined, 'installed'); +test("createTabRouteLocation falls back to the extension route name", () => { + const location = createTabRouteLocation(undefined, "installed"); assert.deepEqual(location, { name: EXTENSION_ROUTE_NAME, query: {}, - hash: '#installed', + hash: "#installed", }); }); -test('createTabRouteLocation prefers route name and preserves params', () => { - const params = { pluginId: 'demo-plugin' }; +test("createTabRouteLocation prefers route name and preserves params", () => { + const params = { pluginId: "demo-plugin" }; const location = createTabRouteLocation( { - name: 'ExtensionDetails', - path: '/extension/demo-plugin', + name: "ExtensionDetails", + path: "/extension/demo-plugin", params, - query: { tab: 'details' }, + query: { tab: "details" }, }, - 'market', + "market", ); assert.deepEqual(location, { - name: 'ExtensionDetails', - params: { pluginId: 'demo-plugin' }, - query: { tab: 'details' }, - hash: '#market', + name: "ExtensionDetails", + params: { pluginId: "demo-plugin" }, + query: { tab: "details" }, + hash: "#market", }); assert.notEqual(location.params, params); }); -test('createTabRouteLocation omits params for path-based routes', () => { - const params = { pluginId: 'demo-plugin' }; +test("createTabRouteLocation omits params for path-based routes", () => { + const params = { pluginId: "demo-plugin" }; const location = createTabRouteLocation( { - path: '/extension/demo-plugin', + path: "/extension/demo-plugin", params, }, - 'installed', + "installed", ); assert.deepEqual(location, { - path: '/extension/demo-plugin', + path: "/extension/demo-plugin", query: {}, - hash: '#installed', + hash: "#installed", }); assert.equal(location.params, undefined); }); -test('replaceTabRoute catches rejected router updates', async () => { - assert.equal(typeof hashRouteTabs.replaceTabRoute, 'function'); +test("replaceTabRoute catches rejected router updates", async () => { + assert.equal(typeof hashRouteTabs.replaceTabRoute, "function"); - const error = new Error('blocked'); + const error = new Error("blocked"); let logged; const router = { replace: async () => { @@ -110,14 +113,14 @@ test('replaceTabRoute catches rejected router updates', async () => { const result = await hashRouteTabs.replaceTabRoute( router, - { name: EXTENSION_ROUTE_NAME, query: { page: '1' } }, - 'installed', + { name: EXTENSION_ROUTE_NAME, query: { page: "1" } }, + "installed", logger, ); assert.equal(result, false); assert.deepEqual(logged, { - message: 'Failed to update extension tab route:', + message: "Failed to update extension tab route:", cause: error, }); }); diff --git a/dashboard/tests/routerReadiness.test.mjs b/dashboard/tests/routerReadiness.test.mjs index 171b271bc..fa6f3185b 100644 --- a/dashboard/tests/routerReadiness.test.mjs +++ b/dashboard/tests/routerReadiness.test.mjs @@ -1,12 +1,14 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; +import test from "node:test"; +import assert from "node:assert/strict"; -test('waitForRouterReadyInBackground returns immediately and logs failures', async () => { - const module = await import('../src/utils/routerReadiness.mjs').catch(() => null); +test("waitForRouterReadyInBackground returns immediately and logs failures", async () => { + const module = await import("../src/utils/routerReadiness.mjs").catch( + () => null, + ); assert.ok(module?.waitForRouterReadyInBackground); - const error = new Error('router blocked'); + const error = new Error("router blocked"); let warned; const readyPromise = Promise.reject(error); const logger = { @@ -23,7 +25,7 @@ test('waitForRouterReadyInBackground returns immediately and logs failures', asy assert.equal(result, undefined); await Promise.resolve(); assert.deepEqual(warned, { - message: 'Router did not become ready after fallback mount:', + message: "Router did not become ready after fallback mount:", cause: error, }); }); diff --git a/dashboard/tests/subsetMdiFont.test.mjs b/dashboard/tests/subsetMdiFont.test.mjs index 8bfdbb01e..2bf5bcf06 100644 --- a/dashboard/tests/subsetMdiFont.test.mjs +++ b/dashboard/tests/subsetMdiFont.test.mjs @@ -1,8 +1,8 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdirSync, writeFileSync, rmSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; import { collectFiles, @@ -12,62 +12,65 @@ import { extractUtilityCss, ICON_CLASS_PATTERN, REQUIRED_ICONS, -} from '../scripts/subset-mdi-font.mjs'; +} from "../scripts/subset-mdi-font.mjs"; // ── Helper: create a temporary directory tree for file-system tests ───────── function makeTmpDir() { - const base = join(tmpdir(), `mdi-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const base = join( + tmpdir(), + `mdi-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); mkdirSync(base, { recursive: true }); return base; } // ── collectFiles ──────────────────────────────────────────────────────────── -test('collectFiles yields files matching given extensions', () => { +test("collectFiles yields files matching given extensions", () => { const tmp = makeTmpDir(); - writeFileSync(join(tmp, 'a.vue'), ''); - writeFileSync(join(tmp, 'b.ts'), ''); - writeFileSync(join(tmp, 'c.txt'), ''); + writeFileSync(join(tmp, "a.vue"), ""); + writeFileSync(join(tmp, "b.ts"), ""); + writeFileSync(join(tmp, "c.txt"), ""); - const files = [...collectFiles(tmp, ['.vue', '.ts'])]; + const files = [...collectFiles(tmp, [".vue", ".ts"])]; assert.equal(files.length, 2); - assert.ok(files.some(f => f.endsWith('a.vue'))); - assert.ok(files.some(f => f.endsWith('b.ts'))); + assert.ok(files.some((f) => f.endsWith("a.vue"))); + assert.ok(files.some((f) => f.endsWith("b.ts"))); rmSync(tmp, { recursive: true }); }); -test('collectFiles recurses into subdirectories', () => { +test("collectFiles recurses into subdirectories", () => { const tmp = makeTmpDir(); - const sub = join(tmp, 'sub'); + const sub = join(tmp, "sub"); mkdirSync(sub); - writeFileSync(join(sub, 'deep.vue'), ''); + writeFileSync(join(sub, "deep.vue"), ""); - const files = [...collectFiles(tmp, ['.vue'])]; + const files = [...collectFiles(tmp, [".vue"])]; assert.equal(files.length, 1); - assert.ok(files[0].endsWith('deep.vue')); + assert.ok(files[0].endsWith("deep.vue")); rmSync(tmp, { recursive: true }); }); -test('collectFiles skips node_modules directories', () => { +test("collectFiles skips node_modules directories", () => { const tmp = makeTmpDir(); - const nm = join(tmp, 'node_modules'); + const nm = join(tmp, "node_modules"); mkdirSync(nm); - writeFileSync(join(nm, 'pkg.vue'), ''); - writeFileSync(join(tmp, 'app.vue'), ''); + writeFileSync(join(nm, "pkg.vue"), ""); + writeFileSync(join(tmp, "app.vue"), ""); - const files = [...collectFiles(tmp, ['.vue'])]; + const files = [...collectFiles(tmp, [".vue"])]; assert.equal(files.length, 1); - assert.ok(files[0].endsWith('app.vue')); + assert.ok(files[0].endsWith("app.vue")); rmSync(tmp, { recursive: true }); }); -test('collectFiles yields nothing for empty directory', () => { +test("collectFiles yields nothing for empty directory", () => { const tmp = makeTmpDir(); - const files = [...collectFiles(tmp, ['.vue'])]; + const files = [...collectFiles(tmp, [".vue"])]; assert.equal(files.length, 0); rmSync(tmp, { recursive: true }); @@ -75,42 +78,48 @@ test('collectFiles yields nothing for empty directory', () => { // ── scanUsedIcons ─────────────────────────────────────────────────────────── -test('scanUsedIcons extracts mdi-* icon names from files', () => { +test("scanUsedIcons extracts mdi-* icon names from files", () => { const tmp = makeTmpDir(); - writeFileSync(join(tmp, 'A.vue'), 'mdi-homemdi-close'); - writeFileSync(join(tmp, 'B.vue'), 'icon="mdi-home"'); + writeFileSync( + join(tmp, "A.vue"), + "mdi-homemdi-close", + ); + writeFileSync(join(tmp, "B.vue"), 'icon="mdi-home"'); - const icons = scanUsedIcons(collectFiles(tmp, ['.vue'])); + const icons = scanUsedIcons(collectFiles(tmp, [".vue"])); assert.ok(icons instanceof Set); - assert.ok(icons.has('mdi-home')); - assert.ok(icons.has('mdi-close')); + assert.ok(icons.has("mdi-home")); + assert.ok(icons.has("mdi-close")); for (const requiredIcon of REQUIRED_ICONS) { assert.ok(icons.has(requiredIcon)); } - const expectedIcons = new Set([...REQUIRED_ICONS, 'mdi-home', 'mdi-close']); + const expectedIcons = new Set([...REQUIRED_ICONS, "mdi-home", "mdi-close"]); assert.deepEqual(icons, expectedIcons); rmSync(tmp, { recursive: true }); }); -test('scanUsedIcons excludes utility classes', () => { +test("scanUsedIcons excludes utility classes", () => { const tmp = makeTmpDir(); - writeFileSync(join(tmp, 'A.vue'), 'mdi-spin mdi-rotate-90 mdi-flip-h mdi-home'); + writeFileSync( + join(tmp, "A.vue"), + "mdi-spin mdi-rotate-90 mdi-flip-h mdi-home", + ); - const icons = scanUsedIcons(collectFiles(tmp, ['.vue'])); - assert.ok(icons.has('mdi-home')); - assert.ok(!icons.has('mdi-spin')); - assert.ok(!icons.has('mdi-rotate-90')); - assert.ok(!icons.has('mdi-flip-h')); + const icons = scanUsedIcons(collectFiles(tmp, [".vue"])); + assert.ok(icons.has("mdi-home")); + assert.ok(!icons.has("mdi-spin")); + assert.ok(!icons.has("mdi-rotate-90")); + assert.ok(!icons.has("mdi-flip-h")); rmSync(tmp, { recursive: true }); }); -test('scanUsedIcons includes all required icons even when no mdi-* icons are found in source', () => { +test("scanUsedIcons includes all required icons even when no mdi-* icons are found in source", () => { const tmp = makeTmpDir(); - writeFileSync(join(tmp, 'A.vue'), '
Hello
'); + writeFileSync(join(tmp, "A.vue"), "
Hello
"); - const icons = scanUsedIcons(collectFiles(tmp, ['.vue'])); + const icons = scanUsedIcons(collectFiles(tmp, [".vue"])); for (const requiredIcon of REQUIRED_ICONS) { assert.ok(icons.has(requiredIcon)); } @@ -119,41 +128,44 @@ test('scanUsedIcons includes all required icons even when no mdi-* icons are fou rmSync(tmp, { recursive: true }); }); -test('scanUsedIcons deduplicates required icons when source already references them', () => { +test("scanUsedIcons deduplicates required icons when source already references them", () => { const tmp = makeTmpDir(); const requiredIcon = [...REQUIRED_ICONS][0]; - writeFileSync(join(tmp, 'A.vue'), `${requiredIcon}mdi-home`); + writeFileSync( + join(tmp, "A.vue"), + `${requiredIcon}mdi-home`, + ); - const icons = [...scanUsedIcons(collectFiles(tmp, ['.vue']))]; - assert.equal(icons.filter(icon => icon === requiredIcon).length, 1); + const icons = [...scanUsedIcons(collectFiles(tmp, [".vue"]))]; + assert.equal(icons.filter((icon) => icon === requiredIcon).length, 1); for (const builtInRequiredIcon of REQUIRED_ICONS) { assert.ok(icons.includes(builtInRequiredIcon)); } - assert.ok(icons.includes('mdi-home')); + assert.ok(icons.includes("mdi-home")); rmSync(tmp, { recursive: true }); }); // ── parseIconCodepoints ───────────────────────────────────────────────────── -test('parseIconCodepoints parses icon definitions from CSS', () => { +test("parseIconCodepoints parses icon definitions from CSS", () => { const css = ` .mdi-home::before { content: "\\F02DC"; } .mdi-close::before { content: "\\F0156"; } `; const map = parseIconCodepoints(css); assert.equal(map.size, 2); - assert.equal(map.get('mdi-home'), 'F02DC'); - assert.equal(map.get('mdi-close'), 'F0156'); + assert.equal(map.get("mdi-home"), "F02DC"); + assert.equal(map.get("mdi-close"), "F0156"); }); -test('parseIconCodepoints handles CSS with semicolons inside braces', () => { +test("parseIconCodepoints handles CSS with semicolons inside braces", () => { const css = `.mdi-check::before { content: "\\F012C"; }`; const map = parseIconCodepoints(css); - assert.equal(map.get('mdi-check'), 'F012C'); + assert.equal(map.get("mdi-check"), "F012C"); }); -test('parseIconCodepoints returns empty map for non-matching CSS', () => { +test("parseIconCodepoints returns empty map for non-matching CSS", () => { const css = `.some-other-class { color: red; }`; const map = parseIconCodepoints(css); assert.equal(map.size, 0); @@ -161,40 +173,46 @@ test('parseIconCodepoints returns empty map for non-matching CSS', () => { // ── resolveUsedIcons ──────────────────────────────────────────────────────── -test('resolveUsedIcons separates resolved and missing icons', () => { - const usedIcons = new Set(['mdi-home', 'mdi-close', 'mdi-nonexistent']); +test("resolveUsedIcons separates resolved and missing icons", () => { + const usedIcons = new Set(["mdi-home", "mdi-close", "mdi-nonexistent"]); const iconMap = new Map([ - ['mdi-home', 'F02DC'], - ['mdi-close', 'F0156'], + ["mdi-home", "F02DC"], + ["mdi-close", "F0156"], ]); - const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons(usedIcons, iconMap); + const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons( + usedIcons, + iconMap, + ); - assert.ok(resolvedIcons.includes('mdi-home')); - assert.ok(resolvedIcons.includes('mdi-close')); + assert.ok(resolvedIcons.includes("mdi-home")); + assert.ok(resolvedIcons.includes("mdi-close")); assert.equal(resolvedIcons.length, 2); - assert.deepEqual(missingIcons, ['mdi-nonexistent']); + assert.deepEqual(missingIcons, ["mdi-nonexistent"]); // Verify subsetChars contains correct Unicode characters assert.equal(subsetChars.length, 2); - assert.equal(subsetChars[0], String.fromCodePoint(0xF02DC)); - assert.equal(subsetChars[1], String.fromCodePoint(0xF0156)); + assert.equal(subsetChars[0], String.fromCodePoint(0xf02dc)); + assert.equal(subsetChars[1], String.fromCodePoint(0xf0156)); }); -test('resolveUsedIcons returns all missing when iconMap is empty', () => { - const usedIcons = new Set(['mdi-home']); +test("resolveUsedIcons returns all missing when iconMap is empty", () => { + const usedIcons = new Set(["mdi-home"]); const iconMap = new Map(); - const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons(usedIcons, iconMap); + const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons( + usedIcons, + iconMap, + ); assert.equal(resolvedIcons.length, 0); - assert.deepEqual(missingIcons, ['mdi-home']); + assert.deepEqual(missingIcons, ["mdi-home"]); assert.equal(subsetChars.length, 0); }); // ── extractUtilityCss ─────────────────────────────────────────────────────── -test('extractUtilityCss removes icon definitions and keeps utility rules', () => { +test("extractUtilityCss removes icon definitions and keeps utility rules", () => { const css = ` @font-face { font-family: "Material Design Icons"; @@ -223,24 +241,24 @@ test('extractUtilityCss removes icon definitions and keeps utility rules', () => const result = extractUtilityCss(css, ICON_CLASS_PATTERN); // Should NOT contain icon definitions - assert.ok(!result.includes('mdi-home')); - assert.ok(!result.includes('mdi-close')); + assert.ok(!result.includes("mdi-home")); + assert.ok(!result.includes("mdi-close")); // Should NOT contain @font-face - assert.ok(!result.includes('@font-face')); + assert.ok(!result.includes("@font-face")); // Should NOT contain base .mdi rules - assert.ok(!result.includes('display: inline-block')); + assert.ok(!result.includes("display: inline-block")); // Should NOT contain source map - assert.ok(!result.includes('sourceMappingURL')); + assert.ok(!result.includes("sourceMappingURL")); // SHOULD contain utility classes - assert.ok(result.includes('mdi-spin')); - assert.ok(result.includes('mdi-18px')); + assert.ok(result.includes("mdi-spin")); + assert.ok(result.includes("mdi-18px")); }); -test('extractUtilityCss returns empty string when only icon defs exist', () => { +test("extractUtilityCss returns empty string when only icon defs exist", () => { const css = ` @font-face { font-family: "MDI"; src: url("font.woff2"); } .mdi:before, .mdi-set { display: inline-block; } @@ -248,25 +266,25 @@ test('extractUtilityCss returns empty string when only icon defs exist', () => { `; const result = extractUtilityCss(css, ICON_CLASS_PATTERN); - assert.equal(result, ''); + assert.equal(result, ""); }); -test('extractUtilityCss handles empty CSS input', () => { - const result = extractUtilityCss('', ICON_CLASS_PATTERN); - assert.equal(result, ''); +test("extractUtilityCss handles empty CSS input", () => { + const result = extractUtilityCss("", ICON_CLASS_PATTERN); + assert.equal(result, ""); }); // ── ICON_CLASS_PATTERN ────────────────────────────────────────────────────── -test('ICON_CLASS_PATTERN matches standard MDI icon definitions', () => { +test("ICON_CLASS_PATTERN matches standard MDI icon definitions", () => { const css = `.mdi-home::before { content: "\\F02DC"; }`; const matches = [...css.matchAll(ICON_CLASS_PATTERN)]; assert.equal(matches.length, 1); - assert.equal(matches[0][1], 'mdi-home'); - assert.equal(matches[0][2], 'F02DC'); + assert.equal(matches[0][1], "mdi-home"); + assert.equal(matches[0][2], "F02DC"); }); -test('ICON_CLASS_PATTERN does not match non-icon classes', () => { +test("ICON_CLASS_PATTERN does not match non-icon classes", () => { const css = `.some-class::before { content: "hello"; }`; const matches = [...css.matchAll(ICON_CLASS_PATTERN)]; assert.equal(matches.length, 0); diff --git a/openspec/abp.md b/openspec/abp.md index 566c91ec4..153128b36 100644 --- a/openspec/abp.md +++ b/openspec/abp.md @@ -468,3 +468,35 @@ class MyPlugin: 3. **配置 Schema 声明**:插件声明配置结构,主进程生成 UI 4. **插件无侵入性**:插件无需了解 AstrBot 内部结构 5. **热重载支持**:配置更新通过 `plugin.config_update` 通知 + +## 序列化与校验(RFC #3210) + +参考 RFC #3210,定义序列化格式和数据校验规范。 + +### 序列化格式 + +| 格式 | 适用场景 | 说明 | +|------|---------|------| +| **JSON** | 调试、跨语言 | 兼容性优先,文本可读 | +| **MessagePack** | 高性能、大数据量 | 二进制,更小数据包 | + +### 数据校验 + +| 层级 | 说明 | +|------|------| +| **消息格式** | 序列化时内建校验 | +| **配置数据** | pydantic v2 数据模型 | +| **工具参数** | JSON Schema Draft-07 | + +## 与 RFC #3210 的关系 + +本规范与 [RFC #3210](https://github.com/AstrBot/AstrBot/issues/3210) 保持一致: + +| RFC #3210 提案 | ABP 规范对应 | +|---------------|-------------| +| 进程间隔离(stdio/websocket) | ✅ `out_of_process` 传输层 | +| JSON-RPC / MessagePack | ✅ RPC 协议 + 序列化 | +| 高性能序列化 | ✅ MessagePack 方案 | +| pydantic 数据校验 | ✅ 配置模型校验 | +| SDK 解耦 | 插件开发依赖 SDK,与本体解耦 | +| 与旧插件共存 | ✅ Stars 迁移下阶段 | diff --git a/openspec/changes/abp-plugin-loader/design.md b/openspec/changes/abp-plugin-loader/design.md index f7102bcce..dd2daedc4 100644 --- a/openspec/changes/abp-plugin-loader/design.md +++ b/openspec/changes/abp-plugin-loader/design.md @@ -44,12 +44,22 @@ class PluginLoader(ABC): ### 2. InProcessPluginLoader 实现 -**决定**:Python 模块直接导入,插件实例化后存入 `Dict[str, PluginInstance]`。 +> **⚠️ 架构约束**:核心加载逻辑在 Rust FFI,Python 仅做胶水层 + +**决定**:`PluginLoader` trait 定义在 Python,`load/unload/reload` 实现调用 Rust FFI(`load_plugin()`/`unload_plugin()`)。 + +```python +class InProcessPluginLoader(PluginLoader): + async def load(self, plugin_id, config, data_dirs): + # 调用 Rust FFI: _core.load_plugin(config) + result = await rust_ffi.load_plugin(plugin_id, config, data_dirs) + return PluginInstance(plugin_id, result.instance, ...) +``` **理由**: -- 无进程通信开销,性能最优 -- 与项目 "Rust 核心 + Python 胶水层" 架构一致 -- 插件需遵循 `ABPPluginProtocol` 接口约定 +- 核心加载逻辑在 Rust(线程安全、错误隔离) +- Python 胶水层仅做类型转换和聚合 +- 符合 config.yaml `rust_core` 规范 ### 3. 工具发现架构 @@ -70,17 +80,20 @@ ToolRegistry ### 4. FFI 绑定链路 -**决定**:Python → Rust `_core.so` → ABP PluginLoader。 +> **⚠️ 禁止 ctypes**:所有 FFI 必须通过 PyO3(rust-ffi.md 规范) + +**决定**:Python → PyO3 `_core.so` → ABP PluginLoader。 ``` -Python PluginManager - → ctypes / cffi 调用 _core.so +Python PluginRegistry (_internal/) + → PyO3 调用 _core.so → Rust abp_plugin_loader_* 函数 → 返回 Python 对象(通过 .pyi 类型提示) ``` **理由**: -- Rust 源码待提交,当前仅使用编译产物 +- PyO3 是 Rust 官方 Python 绑定方案 +- `rust-ffi.md` 明确禁止 ctypes - `_core.pyi` 提供类型检查 - anyio 异步调用通过 `run_in_executor` 封装 @@ -94,9 +107,9 @@ Python PluginManager ## Migration Plan -1. **Phase 1**:PluginLoader trait + InProcessPluginLoader(对现有 OutOfProcess 透明) -2. **Phase 2**:tools/list + ToolRegistry -3. **Phase 3**:FFI 绑定 + config.yaml 集成 +1. **Phase 1**:PluginLoader trait + Python 聚合层(调用 Rust FFI) +2. **Phase 2**:tools/list + ToolRegistry(Rust FFI + Python 聚合) +3. **Phase 3**:PyO3 FFI 绑定 + config.yaml 集成 4. **Phase 4**:测试覆盖 + 文档 ## Open Questions diff --git a/openspec/changes/abp-plugin-loader/specs/abp-plugin-loader/spec.md b/openspec/changes/abp-plugin-loader/specs/abp-plugin-loader/spec.md index 9308067b8..32dbd9fe9 100644 --- a/openspec/changes/abp-plugin-loader/specs/abp-plugin-loader/spec.md +++ b/openspec/changes/abp-plugin-loader/specs/abp-plugin-loader/spec.md @@ -26,17 +26,17 @@ ABP 插件加载器抽象接口,定义进程内/外插件的统一加载方式 - **AND** 重新加载并初始化插件 ### Requirement: In-Process Plugin Loading -进程内插件直接加载为 Python 模块,由 Python 胶水层管理生命周期。 +进程内插件由 Rust FFI 核心管理生命周期,Python 胶水层仅做聚合。 #### Scenario: In-Process Plugin Invocation - **WHEN** 调用进程内插件方法(如 `handle_event`) -- **THEN** 直接通过 Python 对象调用 -- **AND** 不经过序列化/反序列化 -- **AND** 无进程通信开销 +- **THEN** 通过 Rust FFI `load_plugin()` 加载 +- **AND** Python PluginRegistry 聚合插件实例 +- **AND** 调用通过 Rust FFI 转发 #### Scenario: In-Process Plugin Context - **WHEN** 插件实例化时 -- **THEN** 主进程传入 context 对象 +- **THEN** Rust 核心传入 context 对象 - **AND** 传入 user_config(来自握手) - **AND** 传入 data_dirs(数据目录路径) diff --git a/openspec/changes/abp-plugin-loader/specs/abp-tool-discovery/spec.md b/openspec/changes/abp-plugin-loader/specs/abp-tool-discovery/spec.md index 3de744c95..1169d5667 100644 --- a/openspec/changes/abp-plugin-loader/specs/abp-tool-discovery/spec.md +++ b/openspec/changes/abp-plugin-loader/specs/abp-tool-discovery/spec.md @@ -26,6 +26,8 @@ - **AND** 跳过该工具(不注册) ### Requirement: ToolRegistry +> **架构约束**:核心路由在 Rust FFI(`tool_router.rs`),Python 层仅做聚合 + 中心化工具注册表,支持跨插件工具发现和调用。 #### Scenario: Register Tool @@ -35,18 +37,17 @@ #### Scenario: Discover All Tools - **WHEN** 调用 `registry.list_tools()` -- **THEN** 返回所有插件注册的工具 +- **THEN** 聚合所有插件注册的工具(通过 Rust FFI `list_tools()`) - **AND** 每个工具标记来源 plugin_id #### Scenario: Call Tool - **WHEN** 调用 `registry.call_tool(tool_name, args)` -- **THEN** 查找工具所属插件 -- **AND** 转发调用到对应插件 +- **THEN** 转发调用到 Rust FFI `route_tool_call()` - **AND** 返回执行结果 #### Scenario: Tool Not Found - **WHEN** 调用不存在的工具 -- **THEN** 抛出 `ToolNotFoundError` 错误 +- **THEN** Rust FFI 抛出 `ToolNotFoundError` 错误 - **AND** 错误码 -32203 ### Requirement: Cross-Plugin Tool Discovery diff --git a/openspec/changes/abp-plugin-loader/tasks.md b/openspec/changes/abp-plugin-loader/tasks.md index 3660b2cb7..cec0f3556 100644 --- a/openspec/changes/abp-plugin-loader/tasks.md +++ b/openspec/changes/abp-plugin-loader/tasks.md @@ -1,45 +1,49 @@ -## 1. PluginLoader Trait & InProcessPluginLoader +## 1. PluginLoader Trait & Python 聚合层 -- [ ] 1.1 Define `PluginLoader` abstract base class in `astrbot/core/plugin/loader.py` +> **架构边界**:核心加载逻辑在 Rust FFI,Python 胶水层仅做类型转换和聚合 + +- [ ] 1.1 Define `PluginLoader` abstract base class in `astrbot/_internal/protocols/abp/loader.py` - [ ] 1.2 Define `PluginInstance` dataclass (plugin_id, instance, metadata) -- [ ] 1.3 Implement `InProcessPluginLoader.load()` (Python module import) -- [ ] 1.4 Implement `InProcessPluginLoader.unload()` (remove from registry) -- [ ] 1.5 Implement `InProcessPluginLoader.reload()` (reload module) -- [ ] 1.6 Create `PluginRegistry` class (Dict[str, PluginInstance] + singleton) -- [ ] 1.7 Add `register()` and `unregister()` methods to PluginRegistry +- [ ] 1.3 Define `PluginRegistry` class in `astrbot/_internal/protocols/abp/registry.py` (Python 聚合层,调用 Rust FFI) +- [ ] 1.4 Add `register()` and `unregister()` methods to PluginRegistry +- [ ] 1.5 Add `load_plugin()` / `unload_plugin()` wrapper calling Rust FFI ## 2. Tool Discovery & Registry -- [ ] 2.1 Create `astrbot/core/plugin/tool_registry.py` +> **架构边界**:`tool_router.rs` 核心在 Rust,Python 层仅做聚合和转发 + +- [ ] 2.1 Create `astrbot/_internal/protocols/abp/tool_registry.py` - [ ] 2.2 Implement `ToolDef` dataclass (name, description, parameters schema) -- [ ] 2.3 Implement `ToolRegistry.register(plugin_id, tools)` with Schema validation -- [ ] 2.4 Implement `ToolRegistry.list_tools()` (aggregate all plugins) -- [ ] 2.5 Implement `ToolRegistry.call_tool(tool_name, args)` +- [ ] 2.3 Implement `ToolRegistry.register(plugin_id, tools)` with Schema validation (Python) +- [ ] 2.4 Implement `ToolRegistry.list_tools()` (aggregate all plugins via Rust FFI `list_tools()`) +- [ ] 2.5 Implement `ToolRegistry.call_tool(tool_name, args)` (delegate to Rust FFI `route_tool_call()`) - [ ] 2.6 Implement `tools/list` JSON-RPC endpoint in plugin base class - [ ] 2.7 Add JSON Schema Draft-07 validation (jsonschema library) ## 3. FFI Bindings +> **⚠️ 禁止使用 ctypes**:所有 FFI 必须通过 PyO3 绑定(rust-ffi.md 规范) + - [ ] 3.1 Audit existing `_core.pyi` for missing ABP types - [ ] 3.2 Add `plugin_loader_*` FFI function signatures to rust-ffi.md (if missing) -- [ ] 3.3 Implement Python → Rust plugin loader calls via ctypes -- [ ] 3.4 Add async wrapper using `run_in_executor` for FFI calls -- [ ] 3.5 Update `plugin_manager.py` to use FFI bindings +- [ ] 3.3 Implement Python → Rust plugin loader calls via **PyO3** binding +- [ ] 3.4 Add async wrapper using `run_in_executor` for PyO3 FFI calls +- [ ] 3.5 Update `astrbot/_internal/protocols/abp/manager.py` to use FFI bindings ## 4. Configuration Integration - [ ] 4.1 Define `plugins` section in `config.yaml` schema - [ ] 4.2 Implement `PluginConfig` dataclass parsing - [ ] 4.3 Integrate PluginRegistry initialization into AstrBot startup -- [ ] 4.4 Add connection pooling for Unix Socket transport (task 3.4 from original) +- [ ] 4.4 Add connection pooling for Unix Socket transport - [ ] 4.5 Write integration test for config → plugin loading flow ## 5. Testing - [ ] 5.1 Write unit tests for PluginLoader trait -- [ ] 5.2 Write unit tests for InProcessPluginLoader +- [ ] 5.2 Write unit tests for Python PluginRegistry (mock Rust FFI) - [ ] 5.3 Write unit tests for ToolRegistry -- [ ] 5.4 Write integration tests for in-process plugin loading +- [ ] 5.4 Write integration tests for plugin loading (requires Rust FFI stub) - [ ] 5.5 Write integration tests for tools/list + tools/call flow - [ ] 5.6 Run `ruff check .` and `ruff format .` - [ ] 5.7 Run `uvx ty check` for type validation diff --git a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/.openspec.yaml b/openspec/changes/archive/2026-03-27-abp-protocol-implementation/.openspec.yaml deleted file mode 100644 index a61e7c112..000000000 --- a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-03-27 diff --git a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/design.md b/openspec/changes/archive/2026-03-27-abp-protocol-implementation/design.md deleted file mode 100644 index 9efc09e4b..000000000 --- a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/design.md +++ /dev/null @@ -1,73 +0,0 @@ -## Context - -AstrBot 当前使用 Star 插件系统,插件与核心紧耦合。ABP 协议需要实现: -- 插件作为独立服务(进程内/外) -- 主进程不读取插件配置文件,通过协议握手交换 -- 数据目录由主进程分配 -- 支持 Stdio/Unix Socket/HTTP 三种传输方式 -- Rust 核心实现协议逻辑(`astrbot/rust/src/abp/`) - -## Goals / Non-Goals - -**Goals:** -- 实现 ABP 协议核心:握手、消息路由、生命周期管理 -- 支持进程内插件(Python 直接调用)和进程外插件(独立进程) -- 支持 Stdio/Unix Socket/HTTP 传输层 -- 实现工具调用(tools/list, tools/call)和消息处理(plugin.handle_event) -- 实现事件订阅系统(plugin.subscribe, plugin.notify) - -**Non-Goals:** -- 不实现 MCP 协议(独立协议) -- 不实现 A2A/ACP 协议(独立协议) -- 不在 Python 层重复核心逻辑 - -## Decisions - -### 1. Rust 核心实现 vs Python 实现 - -**决定**:核心协议逻辑在 Rust 中实现(`astrbot/rust/src/abp/`),Python 只做 FFI 胶水层。 - -**理由**: -- ABP 协议是高性能路径,消息路由需要低延迟 -- 与项目 "Rust 核心 + Python 胶水层" 架构一致 -- 多语言插件需要稳定的 Rust FFI 接口 - -**替代方案**: -- Python 实现(被否定):性能不足,违反项目架构 -- Go 实现(被否定):引入新语言栈,增加维护成本 - -### 2. 传输层选择 - -**决定**:支持 Stdio(进程启动)、Unix Socket(本地进程间)、HTTP/SSE(远程)三种。 - -**理由**: -- Stdio:最简单的跨语言方案,适合单次请求 -- Unix Socket:低延迟,适合本地进程外插件 -- HTTP/SSE:支持远程插件和 Webhook 通知 - -### 3. 插件配置管理 - -**决定**:主进程不读取插件配置文件,配置通过握手 `initialize` 传递。 - -**理由**: -- 零侵入性:插件无需了解 AstrBot 目录结构 -- 统一管理:所有配置通过 WebUI 管理 -- 安全:主进程控制插件可见的配置 - -### 4. JSON-RPC 2.0 vs 自定义协议 - -**决定**:采用 JSON-RPC 2.0。 - -**理由**: -- 成熟标准,生态丰富 -- 多语言支持(Python、Rust、Go 等) -- 易于调试和测试 - -## Risks / Trade-offs - -| 风险 | 影响 | 缓解措施 | -|------|------|----------| -| Rust FFI 接口不稳定 | Python 胶水层需要频繁调整 | 接口版本化,保持向后兼容 | -| 进程外插件通信延迟 | 工具调用性能下降 | 使用 Unix Socket 代替 HTTP | -| 插件崩溃影响主进程 | 系统稳定性下降 | 进程隔离 + 超时控制 | -| 配置 Schema 同步 | WebUI 表单与实际不一致 | 握手时交换 Schema | diff --git a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/proposal.md b/openspec/changes/archive/2026-03-27-abp-protocol-implementation/proposal.md deleted file mode 100644 index b466efe45..000000000 --- a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/proposal.md +++ /dev/null @@ -1,35 +0,0 @@ -## Why - -AstrBot 需要一个统一的插件协议来实现插件的加载、消息处理、工具调用和生命周期管理。当前的 Star 插件系统与核心紧耦合,插件无法独立运行,缺乏标准化接口。ABP 协议将插件系统从核心中解耦,实现插件的零侵入性和跨语言支持。 - -## What Changes - -- **新增 ABP 协议层**:标准化的插件通信协议(JSON-RPC 2.0) -- **新增插件加载器**:支持进程内(`in_process`)和进程外(`out_of_process`)两种模式 -- **新增传输层**:Stdio、Unix Socket、HTTP/SSE 三种传输方式 -- **新增插件生命周期管理**:初始化、启动、停止、重载、配置更新 -- **新增工具调用机制**:标准化的 `tools/list` 和 `tools/call` 接口 -- **新增消息处理**:通过 `plugin.handle_event` 处理平台事件 -- **新增事件订阅**:插件可订阅 LLM 请求等系统事件 -- **Rust 核心实现**:协议核心逻辑在 Rust 运行时(`astrbot/rust/src/`) - -## Capabilities - -### New Capabilities - -- `abp-protocol`: ABP 协议核心 - 握手、消息路由、生命周期管理 -- `abp-transport`: ABP 传输层 - Stdio/Unix Socket/HTTP 实现 -- `abp-plugin-loader`: ABP 插件加载器 - 进程内/外插件管理 -- `abp-tool-router`: ABP 工具路由 - 跨插件工具发现和调用 -- `abp-event-system`: ABP 事件系统 - 事件订阅和通知 - -### Modified Capabilities - -- `agent-message` (待定): 如果需要与 Agent 消息流程整合,可能需要调整 - -## Impact - -- **新增目录**:`astrbot/rust/src/abp/` - Rust ABP 核心实现 -- **新增 Python 胶水层**:`astrbot/core/plugin/` - Python FFI 胶水代码 -- **配置文件变更**:`config.yaml` 中新增 `plugins` 配置项 -- **依赖**:`jsonrpc-derive`(Rust)、`jsonrpc-core`(Rust) diff --git a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-event-system/spec.md b/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-event-system/spec.md deleted file mode 100644 index cbb356360..000000000 --- a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-event-system/spec.md +++ /dev/null @@ -1,46 +0,0 @@ -## ADDED Requirements - -### Requirement: Event Subscription -插件 SHALL 支持订阅系统事件。 - -#### Scenario: Subscribe to Event -- **WHEN** 主进程发送 `plugin.subscribe` 请求,包含 event_type -- **THEN** 插件注册订阅 -- **AND** 主进程在事件发生时通知插件 - -#### Scenario: Unsubscribe from Event -- **WHEN** 主进程发送 `plugin.unsubscribe` 请求 -- **THEN** 插件取消订阅 -- **AND** 不再接收该类型事件通知 - -#### Scenario: Event Subscription Failed -- **WHEN** 订阅的事件类型不支持 -- **THEN** 插件返回 `-32207` (Event Subscribe Failed) 错误码 - -### Requirement: Event Notification -插件 SHALL 支持接收和发送事件通知。 - -#### Scenario: Receive Event Notification -- **WHEN** 主进程有事件发生(llm_request、tool_called 等) -- **THEN** 主进程发送 `plugin.notify` 通知到插件 -- **AND** 包含 event_type 和 data - -#### Scenario: Send Event from Plugin -- **WHEN** 插件需要通知主进程 -- **THEN** 插件发送 `plugin.notify` 到主进程 -- **AND** 主进程路由到对应处理器 - -### Requirement: Supported Event Types -插件 SHALL 支持以下事件类型。 - -#### Scenario: LLM Request Event -- **WHEN** LLM 开始处理请求 -- **THEN** 主进程通知订阅者,包含请求 ID 和 prompt - -#### Scenario: Tool Called Event -- **WHEN** 工具被调用 -- **THEN** 主进程通知订阅者,包含工具名称和参数 - -#### Scenario: Message Received Event -- **WHEN** 收到用户消息 -- **THEN** 主进程通知订阅者,包含消息内容 diff --git a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-plugin-loader/spec.md b/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-plugin-loader/spec.md deleted file mode 100644 index 5e06929e3..000000000 --- a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-plugin-loader/spec.md +++ /dev/null @@ -1,41 +0,0 @@ -## ADDED Requirements - -### Requirement: In-Process Plugin Loading -进程内插件直接加载为 Python 模块,由 Rust 核心调用。 - -#### Scenario: Load In-Process Plugin -- **WHEN** 配置指定 `load_mode: "in_process"` -- **THEN** 主进程直接导入插件模块 -- **AND** 创建插件实例,传入 context、user_config、data_dirs - -#### Scenario: In-Process Plugin Invocation -- **WHEN** 调用插件方法 -- **THEN** 直接通过 Python 对象调用 -- **AND** 不经过序列化/反序列化 - -### Requirement: Out-of-Process Plugin Loading -进程外插件作为独立进程运行,通过传输层通信。 - -#### Scenario: Load Out-of-Process Plugin -- **WHEN** 配置指定 `load_mode: "out_of_process"` -- **THEN** 主进程启动插件子进程 -- **AND** 建立传输层连接(Stdio/Unix Socket/HTTP) - -#### Scenario: Plugin Process Management -- **WHEN** 插件进程异常退出 -- **THEN** 主进程记录错误日志 -- **AND** 通知相关系统组件 -- **AND** 可选:自动重启插件 - -### Requirement: Plugin Configuration -插件配置通过配置文件声明,不包含敏感信息和内部配置。 - -#### Scenario: Plugin Config Declaration -- **WHEN** 用户在 AstrBot 配置中声明插件 -- **THEN** 仅包含 name、load_mode、command、args、transport、url -- **AND** 不包含插件内部配置 - -#### Scenario: User Config Delivery -- **WHEN** 插件初始化时 -- **THEN** 主进程通过握手将 user_config 传递给插件 -- **AND** user_config 来自 secrets.yaml 或 WebUI diff --git a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-protocol/spec.md b/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-protocol/spec.md deleted file mode 100644 index e3a5bfb4c..000000000 --- a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-protocol/spec.md +++ /dev/null @@ -1,51 +0,0 @@ -## ADDED Requirements - -### Requirement: Initialize Handshake -AstrBot 主进程 SHALL 发送 `initialize` 请求到插件,交换协议版本、客户端信息和配置。 - -#### Scenario: Successful Initialization -- **WHEN** 插件进程启动并准备就绪 -- **THEN** 主进程发送 `initialize` 请求,包含 `protocolVersion`、`clientInfo`、`capabilities`、`pluginConfig`、`dataDirs` -- **AND** 插件返回 `initialized` 响应,包含 `protocolVersion`、`serverInfo`、`capabilities`、`configSchema`、`metadata` - -#### Scenario: Version Mismatch -- **WHEN** 插件不支持客户端的协议版本 -- **THEN** 插件返回 `-32211` (Version Mismatch) 错误码 - -### Requirement: Plugin Lifecycle Management -主进程 SHALL 管理插件的启动、停止、重载和配置更新。 - -#### Scenario: Start Plugin -- **WHEN** 主进程需要激活插件 -- **THEN** 发送 `plugin.start` 请求 -- **AND** 插件进入工作状态 - -#### Scenario: Stop Plugin -- **WHEN** 主进程需要停用插件 -- **THEN** 发送 `plugin.stop` 请求 -- **AND** 插件清理资源并进入空闲状态 - -#### Scenario: Reload Plugin -- **WHEN** 插件需要热重载 -- **THEN** 发送 `plugin.reload` 请求 -- **AND** 插件重新初始化但保持进程 - -#### Scenario: Config Update -- **WHEN** 用户更新插件配置 -- **THEN** 主进程发送 `plugin.config_update` 通知 -- **AND** 插件更新内部配置 - -### Requirement: Plugin Error Handling -插件 SHALL 返回标准错误码用于问题诊断。 - -#### Scenario: Plugin Not Found -- **WHEN** 请求的插件不存在 -- **THEN** 返回 `-32200` (Plugin Not Found) 错误码 - -#### Scenario: Plugin Not Ready -- **WHEN** 请求时插件未完成初始化 -- **THEN** 返回 `-32201` (Plugin Not Ready) 错误码 - -#### Scenario: Plugin Crashed -- **WHEN** 插件进程异常退出 -- **THEN** 主进程检测到并返回 `-32202` (Plugin Crashed) 错误码 diff --git a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-tool-router/spec.md b/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-tool-router/spec.md deleted file mode 100644 index f2ac63faa..000000000 --- a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-tool-router/spec.md +++ /dev/null @@ -1,42 +0,0 @@ -## ADDED Requirements - -### Requirement: Tool Listing -主进程 SHALL 支持通过 `tools/list` 获取插件提供的工具列表。 - -#### Scenario: List Available Tools -- **WHEN** 主进程发送 `tools/list` 请求 -- **THEN** 插件返回可用工具定义列表 -- **AND** 每个工具包含 name、description、parameters - -### Requirement: Tool Calling -主进程 SHALL 支持通过 `tools/call` 调用插件提供的工具。 - -#### Scenario: Call Tool with Arguments -- **WHEN** 主进程发送 `tools/call` 请求,包含 tool name 和 arguments -- **THEN** 插件执行对应工具 -- **AND** 返回工具执行结果(content 数组) - -#### Scenario: Tool Not Found -- **WHEN** 请求的工具名称不存在 -- **THEN** 插件返回 `-32203` (Tool Not Found) 错误码 - -#### Scenario: Tool Call Failed -- **WHEN** 工具执行过程中发生错误 -- **THEN** 插件返回 `-32204` (Tool Call Failed) 错误码 -- **AND** 错误信息包含原因 - -### Requirement: Tool Result Format -工具调用结果 SHALL 符合标准格式。 - -#### Scenario: Successful Tool Result -- **WHEN** 工具成功执行 -- **THEN** 返回 result,包含 content 数组 -- **AND** content 每个元素包含 type 和 text/image/url - -### Requirement: Tool Metadata -工具定义 SHALL 包含完整的元数据用于 LLM 函数调用。 - -#### Scenario: Tool Definition Structure -- **WHEN** 插件声明工具 -- **THEN** 必须包含:name (string)、description (string)、parameters (JSON Schema) -- **AND** parameters 必须符合 JSON Schema Draft-07 格式 diff --git a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-transport/spec.md b/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-transport/spec.md deleted file mode 100644 index 122eefdfc..000000000 --- a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/specs/abp-transport/spec.md +++ /dev/null @@ -1,35 +0,0 @@ -## ADDED Requirements - -### Requirement: Stdio Transport -插件通过标准输入/输出进行 JSON-RPC 通信。 - -#### Scenario: Stdio Message Format -- **WHEN** 发送 JSON-RPC 消息 -- **THEN** 每条消息为单行 JSON,不包含长度前缀 -- **AND** 消息之间以换行符分隔 - -### Requirement: Unix Socket Transport -进程间通过 Unix Socket 进行通信,使用 Content-Length 协议。 - -#### Scenario: Unix Socket Message Format -- **WHEN** 发送 JSON-RPC 消息 -- **THEN** 消息前添加 `Content-Length: \r\n\r\n` 前缀 -- **AND** 消息体为 UTF-8 编码的 JSON - -#### Scenario: Unix Socket Connection Lifecycle -- **WHEN** 主进程连接插件 Unix Socket -- **THEN** 建立持久连接 -- **AND** 双向复用同一连接发送请求/响应 - -### Requirement: HTTP/SSE Transport -远程插件使用 HTTP 进行请求,SSE 进行服务端推送。 - -#### Scenario: HTTP Request -- **WHEN** 主进程发送 HTTP 请求到插件 -- **THEN** 使用 POST 方法,Content-Type 为 `application/json` -- **AND** 请求体为 JSON-RPC 请求对象 - -#### Scenario: SSE Event Stream -- **WHEN** 插件需要推送通知 -- **THEN** 使用 Server-Sent Events 格式 -- **AND** 事件类型为 `plugin.notify` diff --git a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/tasks.md b/openspec/changes/archive/2026-03-27-abp-protocol-implementation/tasks.md deleted file mode 100644 index 1dbbbf265..000000000 --- a/openspec/changes/archive/2026-03-27-abp-protocol-implementation/tasks.md +++ /dev/null @@ -1,70 +0,0 @@ -## 1. Rust Core Infrastructure - -- [x] 1.1 Create `astrbot/rust/src/abp/` directory structure (already exists with abp.rs) -- [x] 1.2 Add ABP dependencies to `astrbot/rust/Cargo.toml` (tokio, serde, etc. already present) -- [x] 1.3 Define ABP error types and codes (-32700 to -32211) - -## 2. ABP Protocol Core (abp-protocol) - -- [x] 2.1 Implement Initialize handshake (C→P: protocolVersion, clientInfo, capabilities, pluginConfig, dataDirs) -- [x] 2.2 Implement Initialize Response (P→C: protocolVersion, serverInfo, capabilities, configSchema, metadata) -- [x] 2.3 Implement Plugin lifecycle methods (plugin.start, plugin.stop, plugin.reload, plugin.config_update) -- [x] 2.4 Implement plugin.error_handler (PluginNotFound, PluginNotReady, PluginCrashed - via error codes) - -## 3. Transport Layer (abp-transport) - -- [x] 3.1 Implement Stdio transport (single-line JSON messages) - in Python transport.py -- [x] 3.2 Implement Unix Socket transport (Content-Length framing) - in Rust abp.rs and Python -- [x] 3.3 Implement HTTP/SSE transport (POST requests, SSE streams) - in Rust abp.rs and Python -- [ ] 3.4 Add connection pooling for Unix Socket - -## 4. Plugin Loader (abp-plugin-loader) - -- [ ] 4.1 Implement PluginLoader trait -- [ ] 4.2 Implement InProcessPluginLoader (Python module loading) -- [x] 4.3 Implement OutOfProcessPluginLoader (process spawning + transport) -- [x] 4.4 Implement plugin config parsing (name, load_mode, command, args, transport, url) - in Rust abp.rs -- [x] 4.5 Implement data directory allocation (dataDirs.root, dataDirs.plugin_data, dataDirs.temp) - -## 5. Tool Router (abp-tool-router) - -- [ ] 5.1 Implement tools/list endpoint (return tool definitions) -- [x] 5.2 Implement tools/call endpoint (execute tool with arguments) - in Rust abp.rs -- [x] 5.3 Implement tool result formatting (content array with type/text) - in Rust abp.rs -- [ ] 5.4 Add tool schema validation (JSON Schema Draft-07) -- [ ] 5.5 Implement cross-plugin tool discovery - -## 6. Event System (abp-event-system) - -- [x] 6.1 Implement plugin.subscribe (register event subscription) - Python client -- [x] 6.2 Implement plugin.unsubscribe (remove event subscription) - Python client -- [x] 6.3 Implement plugin.notify (bidirectional event notification) - Python client -- [x] 6.4 Define event types (llm_request, tool_called, message_received) - in Rust abp.rs -- [x] 6.5 Implement event routing (P→C and C→P notifications) - Python client - -## 7. Python Glue Layer - -- [x] 7.1 Create `astrbot/core/plugin/` Python package -- [ ] 7.2 Implement FFI bindings to Rust ABP core (_core.so) -- [x] 7.3 Create PluginManager Python class (wraps Rust PluginLoader) -- [x] 7.4 Create PluginClient Python class (wraps transport) -- [x] 7.5 Add type stubs in `astrbot/rust/_core.pyi` - -## 8. Integration & Configuration - -- [ ] 8.1 Add plugins section to config.yaml schema -- [ ] 8.2 Implement PluginRegistry (plugin discovery and registration) -- [ ] 8.3 Add ABP initialization to AstrBot startup -- [ ] 8.4 Integrate with existing Star plugin system (backward compatibility) -- [ ] 8.5 Add WebUI support for plugin configuration - -## 9. Testing - -- [ ] 9.1 Write unit tests for ABP protocol core -- [ ] 9.2 Write integration tests for transport layer -- [ ] 9.3 Write tests for plugin loading (in-process and out-of-process) -- [ ] 9.4 Write tests for tool router -- [ ] 9.5 Write tests for event system -- [x] 9.6 Run `ruff check .` and `ruff format .` -- [ ] 9.7 Run `uvx ty check` for type validation -- [x] 9.8 Run `cargo check` and `cargo fmt` for Rust code diff --git a/openspec/config.yaml b/openspec/config.yaml index a57c0d301..ea746a2a3 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -8,7 +8,7 @@ version: "4.16.0" | 规范 | 文件 | 状态 | |------|------|------| | Rust 核心运行时 | `astrbot/rust/` | ⚠️ 仅编译产物,无源码 | -| ABP 插件协议 | `abp.md` | ⚠️ 纯规范,待实现 | +| ABP 插件协议 | `abp.md` | ⚠️ 规范完善(新增序列化+SDK),待实现 | | ACP Agent 通信 | `acp.md` | ⚠️ 纯规范,待实现 | | MCP 客户端 | `mcp.md` | ⚠️ Python 层有简单封装 | | LSP 协议 | `lsp.md` | ❌ 参考文档,不做实现 | @@ -93,6 +93,7 @@ directives: |------|------| | Star | Legacy 插件系统(`astrbot/builtin_stars/`),已被 ABP 取代 | | ABP Plugin | 基于 ABP 协议的插件(见 abp.md),可跨语言 | + | ABP Plugin | 基于 ABP 协议的插件(见 abp.md),可跨语言 | | Galaxy | 概念层,负责加载和管理 Stars/ABP 插件(尚未实现) | | Gateway | HTTP API 网关(Quart + Hypercorn,见 api.md) | | Tool | LLM 可调用的函数,分 Internal/MCP/Skills 三类 | diff --git a/pyproject.toml b/pyproject.toml index 29f29654f..b134349c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,9 +115,6 @@ ignore = [ "E501", ] -[tool.ty] -exclude = ["tests", "tests/**", "**/tests/**"] - [tool.pyright] typeCheckingMode = "basic" pythonVersion = "3.10" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 720fce8bc..63cb1d4bf 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -92,6 +92,7 @@ dependencies = [ "clap", "dirs", "futures-util", + "lazy_static", "pyo3", "pyo3-async-runtimes", "reqwest", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index cd747d338..be0b480cf 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -24,6 +24,7 @@ anyhow = "1" thiserror = "2" async-trait = "0.1" clap = { version = "4", features = ["derive"] } +lazy_static = "1.5" pyo3 = { version = "0.28", features = [ "full", diff --git a/rust/src/a2a.rs b/rust/src/a2a.rs index b9d0f587d..3193e9b52 100644 --- a/rust/src/a2a.rs +++ b/rust/src/a2a.rs @@ -257,7 +257,7 @@ impl A2AClient { task_id: task_id.map(String::from), session_id: session_id.map(String::from), }) - .map_err(|e| AstrBotError::Json(e))?, + .map_err(AstrBotError::Json)?, ); let response = client @@ -276,7 +276,7 @@ impl A2AClient { // Parse into A2AResponse let a2a_response: A2AResponse = - serde_json::from_value(result).map_err(|e| AstrBotError::Json(e))?; + serde_json::from_value(result).map_err(AstrBotError::Json)?; Ok(a2a_response) } @@ -295,7 +295,7 @@ impl A2AClient { &uuid::Uuid::new_v4().to_string(), "SendStreamingMessage", serde_json::to_value(SendStreamingMessageParams { message }) - .map_err(|e| AstrBotError::Json(e))?, + .map_err(AstrBotError::Json)?, ); // For streaming, we return the task ID and let the caller handle SSE @@ -352,7 +352,7 @@ impl A2AClient { .await .map_err(|e| AstrBotError::Protocol(format!("Failed to parse task: {}", e)))?; - let task: Task = serde_json::from_value(result).map_err(|e| AstrBotError::Json(e))?; + let task: Task = serde_json::from_value(result).map_err(AstrBotError::Json)?; self.tasks.insert(task.id.clone(), task.clone()); @@ -386,7 +386,7 @@ impl A2AClient { .await .map_err(|e| AstrBotError::Protocol(format!("Failed to parse canceled task: {}", e)))?; - let task: Task = serde_json::from_value(result).map_err(|e| AstrBotError::Json(e))?; + let task: Task = serde_json::from_value(result).map_err(AstrBotError::Json)?; Ok(task) } diff --git a/rust/src/abp.rs b/rust/src/abp.rs index 69e61d2fd..ea6c5bcf8 100644 --- a/rust/src/abp.rs +++ b/rust/src/abp.rs @@ -368,18 +368,14 @@ impl AbpClient { metadata: plugin.metadata.clone(), tools_count: plugin.tools.len(), }) - } else if let Some(plugin) = self.out_of_process_plugins.get(name) { - Some(PluginInfo { + } else { self.out_of_process_plugins.get(name).map(|plugin| PluginInfo { name: plugin.config.name.clone(), version: plugin.config.version.clone(), load_mode: plugin.config.load_mode, capabilities: plugin.capabilities.clone(), metadata: plugin.metadata.clone(), tools_count: plugin.tools.len(), - }) - } else { - None - } + }) } } /// Call a plugin tool (out-of-process) @@ -457,7 +453,7 @@ impl AbpClient { } }); - let content = serde_json::to_string(&request).map_err(|e| AstrBotError::Json(e))?; + let content = serde_json::to_string(&request).map_err(AstrBotError::Json)?; let header = format!("Content-Length: {}\r\n\r\n", content.len()); stream.write_all(header.as_bytes()).await?; @@ -468,7 +464,7 @@ impl AbpClient { stream.read_to_end(&mut buffer).await?; let response: serde_json::Value = - serde_json::from_slice(&buffer).map_err(|e| AstrBotError::Json(e))?; + serde_json::from_slice(&buffer).map_err(AstrBotError::Json)?; if let Some(error) = response.get("error") { return Err(AstrBotError::Protocol(error.to_string())); @@ -476,7 +472,7 @@ impl AbpClient { let result: ToolResult = serde_json::from_value(response.get("result").cloned().unwrap_or_default()) - .map_err(|e| AstrBotError::Json(e))?; + .map_err(AstrBotError::Json)?; Ok(result) } @@ -593,7 +589,7 @@ impl AbpClient { } }); - let content = serde_json::to_string(&request).map_err(|e| AstrBotError::Json(e))?; + let content = serde_json::to_string(&request).map_err(AstrBotError::Json)?; let header = format!("Content-Length: {}\r\n\r\n", content.len()); stream.write_all(header.as_bytes()).await?; @@ -604,7 +600,7 @@ impl AbpClient { stream.read_to_end(&mut buffer).await?; let response: serde_json::Value = - serde_json::from_slice(&buffer).map_err(|e| AstrBotError::Json(e))?; + serde_json::from_slice(&buffer).map_err(AstrBotError::Json)?; if let Some(error) = response.get("error") { return Err(AstrBotError::Protocol(error.to_string())); @@ -612,7 +608,7 @@ impl AbpClient { let result: HandleEventResult = serde_json::from_value(response.get("result").cloned().unwrap_or_default()) - .map_err(|e| AstrBotError::Json(e))?; + .map_err(AstrBotError::Json)?; Ok(result) } @@ -658,11 +654,7 @@ impl AbpClient { pub fn get_plugin_tools(&self, name: &str) -> Option> { if let Some(plugin) = self.in_process_plugins.get(name) { Some(plugin.tools.clone()) - } else if let Some(plugin) = self.out_of_process_plugins.get(name) { - Some(plugin.tools.clone()) - } else { - None - } + } else { self.out_of_process_plugins.get(name).map(|plugin| plugin.tools.clone()) } } /// Health check for a plugin diff --git a/rust/src/config.rs b/rust/src/config.rs index 2d84352c1..f04007e71 100644 --- a/rust/src/config.rs +++ b/rust/src/config.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::path::Path; use std::path::PathBuf; // ============================================================================ @@ -19,6 +20,7 @@ use std::path::PathBuf; /// Main configuration entry #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub struct AstrBotConfig { #[serde(default)] pub system: SystemConfig, @@ -32,21 +34,10 @@ pub struct AstrBotConfig { pub plugins: HashMap, } -impl Default for AstrBotConfig { - fn default() -> Self { - Self { - system: SystemConfig::default(), - platform: PlatformConfig::default(), - providers: ProvidersConfig::default(), - agent: AgentConfig::default(), - plugins: HashMap::new(), - } - } -} impl AstrBotConfig { /// Load all config files from config directory - pub fn load_from_dir(config_dir: &PathBuf) -> anyhow::Result { + pub fn load_from_dir(config_dir: &Path) -> anyhow::Result { let mut config = AstrBotConfig::default(); let system_path = config_dir.join("system.yaml"); @@ -74,24 +65,20 @@ impl AstrBotConfig { } let plugins_dir = config_dir.join("plugins"); - if plugins_dir.is_dir() { - if let Ok(entries) = std::fs::read_dir(plugins_dir) { + if plugins_dir.is_dir() + && let Ok(entries) = std::fs::read_dir(plugins_dir) { for entry in entries.flatten() { let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) == Some("yaml") { - if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { - if let Ok(content) = std::fs::read_to_string(&path) { - if let Ok(plugin_config) = + if path.extension().and_then(|s| s.to_str()) == Some("yaml") + && let Some(name) = path.file_stem().and_then(|s| s.to_str()) + && let Ok(content) = std::fs::read_to_string(&path) + && let Ok(plugin_config) = serde_json::from_str::(&content) { config.plugins.insert(name.to_string(), plugin_config); } - } - } - } } } - } Ok(config) } @@ -187,6 +174,7 @@ impl Default for ProxyConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub struct TraceConfig { #[serde(default)] pub enable: bool, @@ -196,15 +184,6 @@ pub struct TraceConfig { pub log_path: String, } -impl Default for TraceConfig { - fn default() -> Self { - Self { - enable: false, - log_enable: false, - log_path: String::new(), - } - } -} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -225,6 +204,7 @@ impl Default for TempConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub struct PlatformConfig { #[serde(default)] pub platform_settings: PlatformSettings, @@ -234,15 +214,6 @@ pub struct PlatformConfig { pub platform_specific: PlatformSpecificConfig, } -impl Default for PlatformConfig { - fn default() -> Self { - Self { - platform_settings: PlatformSettings::default(), - platforms: Vec::new(), - platform_specific: PlatformSpecificConfig::default(), - } - } -} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -395,6 +366,7 @@ pub struct PlatformAdapterConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub struct PlatformSpecificConfig { #[serde(default)] pub lark: PlatformSpecificSettings, @@ -404,33 +376,19 @@ pub struct PlatformSpecificConfig { pub discord: PlatformSpecificSettings, } -impl Default for PlatformSpecificConfig { - fn default() -> Self { - Self { - lark: PlatformSpecificSettings::default(), - telegram: PlatformSpecificSettings::default(), - discord: PlatformSpecificSettings::default(), - } - } -} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub struct PlatformSpecificSettings { #[serde(default)] pub pre_ack_emoji: PreAckEmojiConfig, } -impl Default for PlatformSpecificSettings { - fn default() -> Self { - Self { - pre_ack_emoji: PreAckEmojiConfig::default(), - } - } -} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub struct PreAckEmojiConfig { #[serde(default)] pub enable: bool, @@ -438,14 +396,6 @@ pub struct PreAckEmojiConfig { pub emojis: Vec, } -impl Default for PreAckEmojiConfig { - fn default() -> Self { - Self { - enable: false, - emojis: Vec::new(), - } - } -} // ============================================================================ // Providers Config (providers.yaml) @@ -453,18 +403,12 @@ impl Default for PreAckEmojiConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub struct ProvidersConfig { #[serde(default)] pub provider_settings: ProviderSettings, } -impl Default for ProvidersConfig { - fn default() -> Self { - Self { - provider_settings: ProviderSettings::default(), - } - } -} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -502,6 +446,7 @@ impl Default for ProviderSettings { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub struct AgentConfig { #[serde(default)] pub agent_settings: AgentSettings, @@ -515,17 +460,6 @@ pub struct AgentConfig { pub provider_ltm_settings: ProviderLtmSettings, } -impl Default for AgentConfig { - fn default() -> Self { - Self { - agent_settings: AgentSettings::default(), - subagent_orchestrator: SubagentOrchestratorConfig::default(), - provider_stt_settings: ProviderSttSettings::default(), - provider_tts_settings: ProviderTtsSettings::default(), - provider_ltm_settings: ProviderLtmSettings::default(), - } - } -} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -688,6 +622,7 @@ impl Default for ImageCompressOptions { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub struct SubagentOrchestratorConfig { #[serde(default)] pub main_enable: bool, @@ -699,16 +634,6 @@ pub struct SubagentOrchestratorConfig { pub agents: Vec, } -impl Default for SubagentOrchestratorConfig { - fn default() -> Self { - Self { - main_enable: false, - remove_main_duplicate_tools: false, - router_system_prompt: String::new(), - agents: Vec::new(), - } - } -} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -724,6 +649,7 @@ pub struct AgentDefinition { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub struct ProviderSttSettings { #[serde(default)] pub enable: bool, @@ -731,14 +657,6 @@ pub struct ProviderSttSettings { pub provider_id: String, } -impl Default for ProviderSttSettings { - fn default() -> Self { - Self { - enable: false, - provider_id: String::new(), - } - } -} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -922,18 +840,12 @@ pub struct CozeSecrets { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub struct GpgConfig { #[serde(default)] pub security: GpgSecurity, } -impl Default for GpgConfig { - fn default() -> Self { - Self { - security: GpgSecurity::default(), - } - } -} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1003,7 +915,7 @@ pub fn get_config_dir() -> PathBuf { if let Ok(home) = std::env::var("HOME") { return PathBuf::from(home).join(".config").join("astrbot"); } - return PathBuf::from("/root/.config/astrbot"); + PathBuf::from("/root/.config/astrbot") } } @@ -1037,7 +949,7 @@ pub fn get_data_dir() -> PathBuf { if let Ok(home) = std::env::var("HOME") { return PathBuf::from(home).join(".local/share").join("astrbot"); } - return PathBuf::from("/root/.local/share/astrbot"); + PathBuf::from("/root/.local/share/astrbot") } } @@ -1065,7 +977,7 @@ pub fn get_cache_dir() -> PathBuf { if let Ok(home) = std::env::var("HOME") { return PathBuf::from(home).join(".cache").join("astrbot"); } - return PathBuf::from("/root/.cache/astrbot"); + PathBuf::from("/root/.cache/astrbot") } } @@ -1093,6 +1005,6 @@ pub fn get_runtime_dir() -> PathBuf { if let Ok(home) = std::env::var("HOME") { return PathBuf::from(home).join(".local/run").join("astrbot"); } - return PathBuf::from("/run/astrbot"); + PathBuf::from("/run/astrbot") } } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 939176fa5..f9cd24a03 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -49,3 +49,7 @@ pub use stats::RuntimeStats; // Re-export CLI for Python bindings #[cfg(feature = "python")] pub use cli::cli_with_args; + +// Re-export Python bindings +#[cfg(feature = "python")] +pub use python::{get_abp_client, get_orchestrator, PyAbpClient, PyOrchestrator}; diff --git a/rust/src/orchestrator.rs b/rust/src/orchestrator.rs index 652d3c363..561b62fa1 100644 --- a/rust/src/orchestrator.rs +++ b/rust/src/orchestrator.rs @@ -188,23 +188,20 @@ impl Orchestrator { fn periodic_health_check(&self) { debug!("Running periodic health check"); - if let Ok(lsp) = self.lsp.read() { - if !lsp.is_connected() { + if let Ok(lsp) = self.lsp.read() + && !lsp.is_connected() { warn!("LSP client disconnected"); } - } - if let Ok(mcp) = self.mcp.read() { - if !mcp.is_connected() { + if let Ok(mcp) = self.mcp.read() + && !mcp.is_connected() { warn!("MCP client disconnected"); } - } - if let Ok(acp) = self.acp.read() { - if !acp.is_connected() { + if let Ok(acp) = self.acp.read() + && !acp.is_connected() { warn!("ACP client disconnected"); } - } } /// Stop the orchestrator (sync version) @@ -217,11 +214,10 @@ impl Orchestrator { *running = false; } - if let Ok(tx_guard) = self.shutdown_tx.read() { - if let Some(tx) = tx_guard.as_ref() { + if let Ok(tx_guard) = self.shutdown_tx.read() + && let Some(tx) = tx_guard.as_ref() { let _ = tx.send(()); } - } self.shutdown_protocols_sync()?; info!("Orchestrator stopped"); @@ -438,104 +434,3 @@ impl Orchestrator { } } -// ============================================================================ -// Python bindings via PyO3 (sync only) -// ============================================================================ - -#[cfg(feature = "python")] -mod python { - use super::*; - use pyo3::prelude::*; - use pyo3::types::PyDict; - - /// Python wrapper for Orchestrator - #[pyclass] - pub struct PyOrchestrator { - inner: Orchestrator, - } - - #[pymethods] - impl PyOrchestrator { - #[new] - pub fn new() -> Self { - Self { - inner: Orchestrator::new(), - } - } - - /// Start the orchestrator (sync, callable from Python) - pub fn start(&self) -> PyResult<()> { - self.inner - .start_sync() - .map_err(|e| PyErr::new::(e.to_string())) - } - - /// Stop the orchestrator (sync, callable from Python) - pub fn stop(&self) -> PyResult<()> { - self.inner - .stop_sync() - .map_err(|e| PyErr::new::(e.to_string())) - } - - pub fn is_running(&self) -> bool { - self.inner.is_running() - } - - pub fn register_star(&self, name: &str, handler: &str) -> PyResult<()> { - self.inner - .register_star(name, handler) - .map_err(|e| PyErr::new::(e.to_string())) - } - - pub fn unregister_star(&self, name: &str) -> PyResult<()> { - self.inner - .unregister_star(name) - .map_err(|e| PyErr::new::(e.to_string())) - } - - pub fn list_stars(&self) -> Vec { - self.inner.list_stars() - } - - pub fn record_activity(&self) { - self.inner.record_activity(); - } - - pub fn get_stats(&self, py: Python<'_>) -> PyResult> { - let stats = self.inner.stats(); - let dict = PyDict::new(py); - dict.set_item("message_count", stats.message_count())?; - dict.set_item("uptime_seconds", stats.uptime_seconds())?; - dict.set_item("last_activity_time", stats.last_activity_time())?; - Ok(dict.into()) - } - - pub fn set_protocol_connected(&self, protocol: &str, connected: bool) -> PyResult<()> { - self.inner - .set_protocol_connected(protocol, connected) - .map_err(|e| PyErr::new::(e.to_string())) - } - - pub fn get_protocol_status( - &self, - protocol: &str, - py: Python<'_>, - ) -> PyResult>> { - Ok(self.inner.get_protocol_status(protocol).map(|status| { - let dict = PyDict::new(py); - dict.set_item("connected", status.connected).unwrap(); - dict.set_item("name", &status.name).unwrap(); - dict.into() - })) - } - } - - impl Default for PyOrchestrator { - fn default() -> Self { - Self::new() - } - } -} - -#[cfg(feature = "python")] -pub use python::PyOrchestrator; diff --git a/rust/src/protocol.rs b/rust/src/protocol.rs index 03b7ead8e..8fd58583e 100644 --- a/rust/src/protocol.rs +++ b/rust/src/protocol.rs @@ -165,7 +165,7 @@ impl LspClient { } async fn send_json(&mut self, msg: &serde_json::Value) -> Result<(), AstrBotError> { - let content = serde_json::to_string(msg).map_err(|e| AstrBotError::Json(e))?; + let content = serde_json::to_string(msg).map_err(AstrBotError::Json)?; let header = format!("Content-Length: {}\r\n\r\n", content.len()); if let Some(stdin) = &mut self.stdin { @@ -207,14 +207,13 @@ impl LspClient { stdout.read_exact(&mut content).await?; let response: serde_json::Value = - serde_json::from_slice(&content).map_err(|e| AstrBotError::Json(e))?; + serde_json::from_slice(&content).map_err(AstrBotError::Json)?; // Handle response - if let Some(id) = response.get("id").and_then(|v| v.as_i64()) { - if let Some(tx) = self.pending_requests.remove(&id) { + if let Some(id) = response.get("id").and_then(|v| v.as_i64()) + && let Some(tx) = self.pending_requests.remove(&id) { let _ = tx.send(response.to_string()); } - } } } Ok(()) @@ -247,10 +246,10 @@ impl ProtocolClient for LspClient { self.connected = false; // Try graceful shutdown first - if let Err(_) = self.send_notification("shutdown", None).await { + if self.send_notification("shutdown", None).await.is_err() { // Ignore errors during shutdown } - if let Err(_) = self.send_notification("exit", None).await { + if self.send_notification("exit", None).await.is_err() { // Ignore errors during exit } @@ -449,11 +448,10 @@ impl ProtocolClient for McpClient { async fn disconnect(&mut self) -> Result<(), AstrBotError> { self.connected = false; - if let McpTransport::Stdio { child } = &mut self.transport { - if let Some(mut child) = child.take() { + if let McpTransport::Stdio { child } = &mut self.transport + && let Some(mut child) = child.take() { let _ = child.kill().await; } - } tracing::info!("MCP client disconnected"); Ok(()) @@ -558,7 +556,7 @@ impl AcpClient { } async fn send_json(&mut self, msg: &serde_json::Value) -> Result<(), AstrBotError> { - let content = serde_json::to_string(msg).map_err(|e| AstrBotError::Json(e))?; + let content = serde_json::to_string(msg).map_err(AstrBotError::Json)?; let header = serde_json::to_string(&serde_json::json!({ "content-length": content.len() }))? + "\n"; diff --git a/rust/src/python.rs b/rust/src/python.rs index 6288fe5b7..affa9e3c2 100644 --- a/rust/src/python.rs +++ b/rust/src/python.rs @@ -1,6 +1,10 @@ //! Python bindings for AstrBot Core +//! +//! Exposes Rust core functionality to Python via PyO3. use pyo3::prelude::*; +use pyo3::types::PyDict; +use std::sync::{Arc, RwLock}; /// Run the AstrBot Core CLI. #[pyfunction] @@ -9,9 +13,247 @@ pub fn cli(args: Vec) -> PyResult<()> { .map_err(|e| PyErr::new::(e.to_string())) } +// ============================================================================ +// Global singletons for Python access +// ============================================================================ + +lazy_static::lazy_static! { + static ref ORCHESTRATOR: Arc> = + Arc::new(RwLock::new(crate::orchestrator::Orchestrator::new())); +} + +// ============================================================================ +// ABP Client Python bindings +// ============================================================================ + +/// Python wrapper for AbpClient +#[pyclass] +pub struct PyAbpClient { + inner: Arc>, +} + +#[pymethods] +impl PyAbpClient { + #[new] + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(crate::abp::AbpClient::new())), + } + } + + pub fn is_connected(&self) -> bool { + self.inner.read().map(|c| c.is_connected()).unwrap_or(false) + } + + pub fn register_in_process_plugin(&self, name: &str, version: &str) -> PyResult<()> { + let plugin_config = crate::abp::PluginConfig { + name: name.to_string(), + version: version.to_string(), + load_mode: crate::abp::PluginLoadMode::InProcess, + ..Default::default() + }; + + self.inner.write().map(|mut c| { + c.register_in_process_plugin(plugin_config); + }).map_err(|e| PyErr::new::(format!("Lock error: {}", e))) + } + + pub fn register_out_of_process_plugin( + &self, + name: &str, + version: &str, + command: Option, + transport: Option, + ) -> PyResult<()> { + let transport = match transport.as_deref() { + Some("unix_socket") => crate::abp::PluginTransport::UnixSocket, + Some("http") => crate::abp::PluginTransport::Http, + _ => crate::abp::PluginTransport::Stdio, + }; + + let plugin_config = crate::abp::PluginConfig { + name: name.to_string(), + version: version.to_string(), + load_mode: crate::abp::PluginLoadMode::OutOfProcess, + command, + transport, + ..Default::default() + }; + + self.inner.write().map(|mut c| { + c.register_out_of_process_plugin(plugin_config); + }).map_err(|e| PyErr::new::(format!("Lock error: {}", e))) + } + + pub fn unregister_plugin(&self, name: &str) -> PyResult<()> { + self.inner.write().map(|mut c| { + c.unregister_plugin(name); + }).map_err(|e| PyErr::new::(format!("Lock error: {}", e))) + } + + pub fn list_plugins(&self) -> Vec { + self.inner.read().map(|c| c.list_plugins()).unwrap_or_default() + } + + pub fn get_plugin_info(&self, name: &str, py: Python<'_>) -> Option> { + self.inner.read().ok().and_then(|c| { + c.get_plugin_info(name).map(|info| { + let dict = PyDict::new(py); + dict.set_item("name", &info.name).ok(); + dict.set_item("version", &info.version).ok(); + dict.set_item("load_mode", format!("{}", info.load_mode)).ok(); + dict.set_item("tools_count", info.tools_count).ok(); + // Capabilities + let caps = PyDict::new(py); + caps.set_item("tools", info.capabilities.tools).ok(); + caps.set_item("handlers", info.capabilities.handlers).ok(); + caps.set_item("events", info.capabilities.events).ok(); + caps.set_item("resources", info.capabilities.resources).ok(); + dict.set_item("capabilities", caps).ok(); + // Metadata + if let Some(ref meta) = info.metadata { + let meta_dict = PyDict::new(py); + meta_dict.set_item("display_name", meta.display_name.as_deref().unwrap_or("")).ok(); + meta_dict.set_item("description", meta.description.as_deref().unwrap_or("")).ok(); + meta_dict.set_item("author", meta.author.as_deref().unwrap_or("")).ok(); + dict.set_item("metadata", meta_dict).ok(); + } + dict.into() + }) + }) + } + + pub fn health_check(&self, name: &str) -> bool { + self.inner.read().map(|c| c.health_check(name)).unwrap_or(false) + } +} + +impl Default for PyAbpClient { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// Orchestrator Python bindings +// ============================================================================ + +/// Python wrapper for Orchestrator +#[pyclass] +pub struct PyOrchestrator { + inner: Arc>, +} + +#[pymethods] +impl PyOrchestrator { + #[new] + pub fn new() -> Self { + Self { + inner: ORCHESTRATOR.clone(), + } + } + + /// Start the orchestrator + pub fn start(&self) -> PyResult<()> { + self.inner.write().map(|o| { + o.start_sync() + }).map_err(|e| PyErr::new::(e.to_string()))? + .map_err(|e| PyErr::new::(e.to_string())) + } + + /// Stop the orchestrator + pub fn stop(&self) -> PyResult<()> { + self.inner.write().map(|o| { + o.stop_sync() + }).map_err(|e| PyErr::new::(e.to_string()))? + .map_err(|e| PyErr::new::(e.to_string())) + } + + pub fn is_running(&self) -> bool { + self.inner.read().map(|o| o.is_running()).unwrap_or(false) + } + + pub fn register_star(&self, name: &str, handler: &str) -> PyResult<()> { + self.inner.write().map(|o| { + o.register_star(name, handler) + }).map_err(|e| PyErr::new::(e.to_string()))? + .map_err(|e| PyErr::new::(e.to_string())) + } + + pub fn unregister_star(&self, name: &str) -> PyResult<()> { + self.inner.write().map(|o| { + o.unregister_star(name) + }).map_err(|e| PyErr::new::(e.to_string()))? + .map_err(|e| PyErr::new::(e.to_string())) + } + + pub fn list_stars(&self) -> Vec { + self.inner.read().map(|o| o.list_stars()).unwrap_or_default() + } + + pub fn record_activity(&self) { + if let Ok(o) = self.inner.read() { o.record_activity() } + } + + pub fn get_stats(&self, py: Python<'_>) -> Py { + let dict = PyDict::new(py); + if let Ok(o) = self.inner.read() { + let stats = o.stats(); + dict.set_item("message_count", stats.message_count()).ok(); + dict.set_item("uptime_seconds", stats.uptime_seconds()).ok(); + dict.set_item("last_activity_time", stats.last_activity_time()).ok(); + } + dict.into() + } + + pub fn set_protocol_connected(&self, protocol: &str, connected: bool) -> PyResult<()> { + self.inner.write().map(|o| { + o.set_protocol_connected(protocol, connected) + }).map_err(|e| PyErr::new::(e.to_string()))? + .map_err(|e| PyErr::new::(e.to_string())) + } + + pub fn get_protocol_status(&self, protocol: &str, py: Python<'_>) -> Option> { + self.inner.read().ok().and_then(|o| { + o.get_protocol_status(protocol).map(|status| { + let dict = PyDict::new(py); + dict.set_item("connected", status.connected).ok(); + dict.set_item("name", &status.name).ok(); + dict.into() + }) + }) + } +} + +impl Default for PyOrchestrator { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// Module exports +// ============================================================================ + +/// Get the global orchestrator instance +#[pyfunction] +pub fn get_orchestrator() -> PyOrchestrator { + PyOrchestrator::new() +} + +/// Get a new ABP client instance +#[pyfunction] +pub fn get_abp_client() -> PyAbpClient { + PyAbpClient::new() +} + /// Python module for AstrBot Core. -#[pymodule] +#[pymodule(module = "_core")] pub fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(cli, m)?)?; + m.add_function(wrap_pyfunction!(get_orchestrator, m)?)?; + m.add_function(wrap_pyfunction!(get_abp_client, m)?)?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/rust/src/server.rs b/rust/src/server.rs index 127880ea7..a9070f07d 100644 --- a/rust/src/server.rs +++ b/rust/src/server.rs @@ -75,7 +75,7 @@ impl JwtAuth { iat: now, }; - let header = format!("{{\"alg\":\"HS256\",\"typ\":\"JWT\"}}"); + let header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".to_string(); let header_b64 = BASE64.encode(header.as_bytes()); let payload_b64 = BASE64 .encode(serde_json::to_vec(&claims).map_err(|e| ServerError::Jwt(e.to_string()))?); @@ -121,7 +121,7 @@ impl JwtAuth { let mut hasher = DefaultHasher::new(); data.hash(&mut hasher); self.secret.hash(&mut hasher); - BASE64.encode(&hasher.finish().to_be_bytes()) + BASE64.encode(hasher.finish().to_be_bytes()) } } @@ -540,7 +540,7 @@ async fn handle_ws_message( if !response.is_null() { state .ws_manager - .send_to(&connection_id, &serde_json::to_string(&response).unwrap()) + .send_to(connection_id, &serde_json::to_string(&response).unwrap()) .await .ok(); } diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index 2454c09c1..cc8672803 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -1136,3 +1136,25 @@ async def test_query_injects_reasoning_effort_none_for_ollama(monkeypatch): assert extra_body["temperature"] == 0.1 finally: await provider.terminate() + + +@pytest.mark.asyncio +async def test_prepare_chat_payload_strips_non_json_serializable_kwargs(): + """abort_signal (asyncio.Event) passed via **kwargs must be filtered out. + + Regression test: previously **kwargs merge caused abort_signal to end up in + payloads, triggering "Object of type Event is not JSON serializable" when + the OpenAI client tried to serialize the request body. + """ + import asyncio + provider = _make_provider() + try: + payloads, _ = await provider._prepare_chat_payload( + prompt="hello", + abort_signal=asyncio.Event(), # non-serializable object + max_tokens=1024, # normal kwarg that SHOULD be kept + ) + assert "abort_signal" not in payloads + assert payloads.get("max_tokens") == 1024 + finally: + await provider.terminate()