mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-24 16:39:52 +08:00
refactor(dashboard): remove unused sha256 utility
The sha256.ts module is no longer used by any frontend code.
This commit is contained in:
@@ -1,12 +0,0 @@
|
||||
"""AstrBot internal agents module.
|
||||
|
||||
This module contains internal agent implementations.
|
||||
For public API, use astrbot.api.
|
||||
"""
|
||||
|
||||
from .openai_agents import OpenAIAgentsRunner, astrbot_tool_to_agents_tool
|
||||
|
||||
__all__ = [
|
||||
"OpenAIAgentsRunner",
|
||||
"astrbot_tool_to_agents_tool",
|
||||
]
|
||||
@@ -1,21 +0,0 @@
|
||||
"""OpenAI Agents SDK integration for AstrBot.
|
||||
|
||||
This module provides integration with the openai-agents library,
|
||||
allowing AstrBot to leverage the openai-agents Agent implementation
|
||||
while using its existing tool and provider infrastructure.
|
||||
|
||||
Usage:
|
||||
from astrbot._internal.agents import OpenAIAgentsRunner
|
||||
|
||||
runner = OpenAIAgentsRunner(agent_config)
|
||||
async for response in runner.run():
|
||||
print(response)
|
||||
"""
|
||||
|
||||
from .runner import OpenAIAgentsRunner
|
||||
from .tool_adapter import astrbot_tool_to_agents_tool
|
||||
|
||||
__all__ = [
|
||||
"OpenAIAgentsRunner",
|
||||
"astrbot_tool_to_agents_tool",
|
||||
]
|
||||
@@ -1,180 +0,0 @@
|
||||
"""OpenAI Agents SDK Runner for AstrBot.
|
||||
|
||||
This module provides an integration layer between AstrBot's agent system
|
||||
and the openai-agents library from OpenAI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
else:
|
||||
from typing_extensions import override
|
||||
|
||||
from agents import Agent as OpenAIAgent
|
||||
from agents import Model, Runner, RunResult
|
||||
from agents.run_config import RunConfig
|
||||
|
||||
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
||||
from astrbot.core.agent.response import AgentResponse, AgentResponseData
|
||||
from astrbot.core.agent.run_context import ContextWrapper, TContext
|
||||
from astrbot.core.agent.runners.base import AgentState, BaseAgentRunner
|
||||
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import LLMResponse, ProviderRequest
|
||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||
from astrbot.core.provider.provider import Provider
|
||||
|
||||
from .tool_adapter import astrbot_tool_to_agents_tool
|
||||
|
||||
|
||||
class OpenAIAgentsRunner(BaseAgentRunner[TContext]):
|
||||
"""An agent runner that uses the openai-agents library.
|
||||
|
||||
This runner wraps the openai-agents Agent and integrates it with
|
||||
AstrBot's existing infrastructure including tools, hooks, and context.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: Model | str,
|
||||
instructions: str | None = None,
|
||||
tools: list[FunctionTool] | None = None,
|
||||
tool_manager: FunctionToolManager | None = None,
|
||||
agent_name: str = "AstrBotAgent",
|
||||
**openai_agents_kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the openai-agents runner.
|
||||
|
||||
Args:
|
||||
model: The model to use (either a Model instance or a model name string)
|
||||
instructions: System prompt/instructions for the agent
|
||||
tools: List of AstrBot FunctionTool instances
|
||||
tool_manager: Optional FunctionToolManager for tool resolution
|
||||
agent_name: Name of the agent
|
||||
**openai_agents_kwargs: Additional arguments passed to openai-agents Agent
|
||||
"""
|
||||
super().__init__()
|
||||
self._model = model
|
||||
self._instructions = instructions
|
||||
self._tools = tools or []
|
||||
self._tool_manager = tool_manager
|
||||
self._agent_name = agent_name
|
||||
self._openai_agents_kwargs = openai_agents_kwargs
|
||||
|
||||
self._agent: OpenAIAgent[Any] | None = None
|
||||
self._run_result: RunResult | None = None
|
||||
self._run_context: ContextWrapper[TContext] | None = None
|
||||
self._agent_hooks: BaseAgentRunHooks[TContext] | None = None
|
||||
self._state = AgentState.IDLE
|
||||
|
||||
def _create_agents_tools(self) -> list[Any]:
|
||||
"""Convert AstrBot tools to openai-agents tools."""
|
||||
agents_tools = []
|
||||
for tool in self._tools:
|
||||
handler = self._get_tool_handler(tool.name)
|
||||
agents_tool = astrbot_tool_to_agents_tool(tool, handler)
|
||||
agents_tools.append(agents_tool)
|
||||
return agents_tools
|
||||
|
||||
def _get_tool_handler(self, tool_name: str) -> Any:
|
||||
"""Get the handler function for a tool by name."""
|
||||
if self._tool_manager:
|
||||
tool = self._tool_manager.get_func(tool_name)
|
||||
if tool and tool.handler:
|
||||
return tool.handler
|
||||
for tool in self._tools:
|
||||
if tool.name == tool_name and hasattr(tool, "handler"):
|
||||
return tool.handler
|
||||
raise ValueError(f"Tool handler not found for: {tool_name}")
|
||||
|
||||
@override
|
||||
async def reset(
|
||||
self,
|
||||
provider: Provider,
|
||||
request: ProviderRequest,
|
||||
run_context: ContextWrapper[TContext],
|
||||
tool_executor: BaseFunctionToolExecutor[TContext],
|
||||
agent_hooks: BaseAgentRunHooks[TContext],
|
||||
streaming: bool = False,
|
||||
enforce_max_turns: int = -1,
|
||||
llm_compress_instruction: str | None = None,
|
||||
llm_compress_keep_recent: int = 0,
|
||||
llm_compress_provider: Provider | None = None,
|
||||
truncate_turns: int = 1,
|
||||
custom_token_counter: Any = None,
|
||||
custom_compressor: Any = None,
|
||||
tool_schema_mode: str | None = "full",
|
||||
fallback_providers: list[Provider] | None = None,
|
||||
provider_config: dict | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Reset the agent to its initial state."""
|
||||
self._run_context = run_context
|
||||
self._agent_hooks = agent_hooks
|
||||
self._state = AgentState.IDLE
|
||||
self._run_result = None
|
||||
|
||||
agents_tools = self._create_agents_tools()
|
||||
|
||||
self._agent = OpenAIAgent(
|
||||
name=self._agent_name,
|
||||
instructions=self._instructions,
|
||||
tools=agents_tools,
|
||||
**self._openai_agents_kwargs,
|
||||
)
|
||||
|
||||
@override
|
||||
async def step(self) -> AsyncGenerator[AgentResponse, None]:
|
||||
"""Process a single step of the agent (not directly supported)."""
|
||||
raise NotImplementedError(
|
||||
"step() is not directly supported. Use step_until_done() instead."
|
||||
)
|
||||
|
||||
@override
|
||||
async def step_until_done(
|
||||
self, max_step: int
|
||||
) -> AsyncGenerator[AgentResponse, None]:
|
||||
"""Run the agent until completion or max steps."""
|
||||
if not self._agent or not self._run_context:
|
||||
raise RuntimeError("Agent not initialized. Call reset() first.")
|
||||
|
||||
self._state = AgentState.RUNNING
|
||||
|
||||
run_config = RunConfig(max_turns=max_step)
|
||||
|
||||
try:
|
||||
self._run_result = await Runner.run(
|
||||
self._agent,
|
||||
input=self._run_context.messages,
|
||||
run_config=run_config,
|
||||
)
|
||||
self._state = AgentState.DONE
|
||||
|
||||
if self._run_result:
|
||||
chain = MessageChain().message(self._run_result.final_output)
|
||||
yield AgentResponse(
|
||||
type="llm_result",
|
||||
data=AgentResponseData(chain=chain),
|
||||
)
|
||||
except Exception:
|
||||
self._state = AgentState.ERROR
|
||||
raise
|
||||
|
||||
def done(self) -> bool:
|
||||
"""Check if the agent has completed its task."""
|
||||
return self._state == AgentState.DONE
|
||||
|
||||
def get_final_llm_resp(self) -> LLMResponse | None:
|
||||
"""Get the final LLM response from the agent run."""
|
||||
if not self._run_result:
|
||||
return None
|
||||
return LLMResponse(
|
||||
role="assistant",
|
||||
completion_text=self._run_result.final_output,
|
||||
)
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Adapter to convert AstrBot FunctionTool to openai-agents FunctionTool.
|
||||
|
||||
This module provides utilities to convert between AstrBot's tool representation
|
||||
and the openai-agents library's FunctionTool format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from agents import FunctionTool as AgentsFunctionTool
|
||||
from agents.tool import ToolContext
|
||||
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
|
||||
def create_agents_tool(
|
||||
name: str,
|
||||
description: str,
|
||||
params_json_schema: dict[str, Any],
|
||||
on_invoke_tool: Callable[[ToolContext[Any], str], Awaitable[Any]],
|
||||
) -> AgentsFunctionTool:
|
||||
"""Create an openai-agents FunctionTool from a handler function.
|
||||
|
||||
This is a helper to create tools that integrate with the openai-agents SDK.
|
||||
"""
|
||||
return AgentsFunctionTool(
|
||||
name=name,
|
||||
description=description,
|
||||
params_json_schema=params_json_schema,
|
||||
on_invoke_tool=on_invoke_tool,
|
||||
)
|
||||
|
||||
|
||||
def astrbot_tool_to_agents_tool(
|
||||
tool: FunctionTool,
|
||||
handler: Callable[..., Awaitable[Any]],
|
||||
) -> AgentsFunctionTool:
|
||||
"""Convert an AstrBot FunctionTool to an openai-agents FunctionTool.
|
||||
|
||||
Args:
|
||||
tool: The AstrBot FunctionTool to convert
|
||||
handler: The async function to call when the tool is invoked.
|
||||
Should have the signature: async def handler(tool_context, tool_name) -> Any
|
||||
|
||||
Returns:
|
||||
An openai-agents FunctionTool that wraps the AstrBot tool
|
||||
"""
|
||||
|
||||
async def wrapper(tool_context: ToolContext[Any], tool_name: str) -> Any:
|
||||
return await handler(tool.context, tool.name)
|
||||
|
||||
return AgentsFunctionTool(
|
||||
name=tool.name,
|
||||
description=tool.desc,
|
||||
params_json_schema=tool.parameters,
|
||||
on_invoke_tool=wrapper,
|
||||
)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""MCP module - Model Context Protocol client and tool implementations.
|
||||
|
||||
This module provides MCP client functionality and MCP tool wrappers.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .client import MCPClient
|
||||
from .config import (
|
||||
DEFAULT_MCP_CONFIG,
|
||||
get_mcp_config_path,
|
||||
load_mcp_config,
|
||||
save_mcp_config,
|
||||
)
|
||||
from .tool import MCPTool
|
||||
|
||||
|
||||
# Exceptions
|
||||
class MCPInitError(Exception):
|
||||
"""Base exception for MCP initialization failures."""
|
||||
|
||||
|
||||
class MCPInitTimeoutError(asyncio.TimeoutError, MCPInitError):
|
||||
"""Raised when MCP client initialization exceeds the configured timeout."""
|
||||
|
||||
|
||||
class MCPAllServicesFailedError(MCPInitError):
|
||||
"""Raised when all configured MCP services fail to initialize."""
|
||||
|
||||
|
||||
class MCPShutdownTimeoutError(asyncio.TimeoutError):
|
||||
"""Raised when MCP shutdown exceeds the configured timeout."""
|
||||
|
||||
def __init__(self, names: list[str], timeout: float) -> None:
|
||||
self.names = names
|
||||
self.timeout = timeout
|
||||
message = f"MCP 服务关闭超时({timeout:g} 秒):{', '.join(names)}"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPInitSummary:
|
||||
"""Summary of MCP initialization results."""
|
||||
|
||||
total: int
|
||||
success: int
|
||||
failed: list[str]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MCP_CONFIG",
|
||||
"MCPAllServicesFailedError",
|
||||
"MCPClient",
|
||||
"MCPInitError",
|
||||
"MCPInitSummary",
|
||||
"MCPInitTimeoutError",
|
||||
"MCPShutdownTimeoutError",
|
||||
"MCPTool",
|
||||
"get_mcp_config_path",
|
||||
"load_mcp_config",
|
||||
"save_mcp_config",
|
||||
]
|
||||
@@ -1,411 +0,0 @@
|
||||
"""MCP client implementation."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from contextlib import AsyncExitStack
|
||||
from datetime import timedelta
|
||||
|
||||
from tenacity import (
|
||||
before_sleep_log,
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.utils.log_pipe import LogPipe
|
||||
|
||||
try:
|
||||
import anyio
|
||||
import mcp
|
||||
from mcp.client.sse import sse_client
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
logger.warning(
|
||||
"Warning: Missing 'mcp' dependency, MCP services will be unavailable."
|
||||
)
|
||||
|
||||
try:
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
logger.warning(
|
||||
"Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.",
|
||||
)
|
||||
|
||||
|
||||
def _prepare_config(config: dict) -> dict:
|
||||
"""Prepare configuration, handle nested format."""
|
||||
if config.get("mcpServers"):
|
||||
first_key = next(iter(config["mcpServers"]))
|
||||
config = config["mcpServers"][first_key]
|
||||
config.pop("active", None)
|
||||
return config
|
||||
|
||||
|
||||
def _prepare_stdio_env(config: dict) -> dict:
|
||||
"""Preserve Windows executable resolution for stdio subprocesses."""
|
||||
if sys.platform != "win32":
|
||||
return config
|
||||
|
||||
pathext = os.environ.get("PATHEXT")
|
||||
if not pathext:
|
||||
return config
|
||||
|
||||
prepared = config.copy()
|
||||
env = dict(prepared.get("env") or {})
|
||||
env.setdefault("PATHEXT", pathext)
|
||||
prepared["env"] = env
|
||||
return prepared
|
||||
|
||||
|
||||
async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
|
||||
"""Quick test MCP server connectivity."""
|
||||
import aiohttp
|
||||
|
||||
cfg = _prepare_config(config.copy())
|
||||
|
||||
url = cfg["url"]
|
||||
headers = cfg.get("headers", {})
|
||||
timeout = cfg.get("timeout", 10)
|
||||
|
||||
try:
|
||||
if "transport" in cfg:
|
||||
transport_type = cfg["transport"]
|
||||
elif "type" in cfg:
|
||||
transport_type = cfg["type"]
|
||||
else:
|
||||
raise Exception("MCP connection config missing transport or type field")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
if transport_type == "streamable_http":
|
||||
test_payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "initialize",
|
||||
"id": 0,
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "test-client", "version": "1.2.3"},
|
||||
},
|
||||
}
|
||||
async with session.post(
|
||||
url,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
},
|
||||
json=test_payload,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return True, ""
|
||||
return False, f"HTTP {response.status}: {response.reason}"
|
||||
else:
|
||||
async with session.get(
|
||||
url,
|
||||
headers={
|
||||
**headers,
|
||||
"Accept": "application/json, text/event-stream",
|
||||
},
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return True, ""
|
||||
return False, f"HTTP {response.status}: {response.reason}"
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return False, f"Connection timeout: {timeout} seconds"
|
||||
except Exception as e:
|
||||
return False, f"{e!s}"
|
||||
|
||||
|
||||
class MCPClient:
|
||||
def __init__(self) -> None:
|
||||
# Initialize session and client objects
|
||||
self.session: mcp.ClientSession | None = None
|
||||
self.exit_stack = AsyncExitStack()
|
||||
self._old_exit_stacks: list[AsyncExitStack] = [] # Track old stacks for cleanup
|
||||
|
||||
self.name: str | None = None
|
||||
self.active: bool = True
|
||||
self.tools: list[mcp.Tool] = []
|
||||
self.server_errlogs: list[str] = []
|
||||
self.running_event = asyncio.Event()
|
||||
self.process_pid: int | None = None
|
||||
|
||||
# Store connection config for reconnection
|
||||
self._mcp_server_config: dict | None = None
|
||||
self._server_name: str | None = None
|
||||
self._reconnect_lock = asyncio.Lock() # Lock for thread-safe reconnection
|
||||
self._reconnecting: bool = False # For logging and debugging
|
||||
|
||||
@staticmethod
|
||||
def _extract_stdio_process_pid(streams_context: object) -> int | None:
|
||||
"""Best-effort extraction for stdio subprocess PID used by lease cleanup.
|
||||
|
||||
TODO(refactor): replace this async-generator frame introspection with a
|
||||
stable MCP library hook once the upstream transport exposes process PID.
|
||||
"""
|
||||
generator = getattr(streams_context, "gen", None)
|
||||
frame = getattr(generator, "ag_frame", None)
|
||||
if frame is None:
|
||||
return None
|
||||
process = frame.f_locals.get("process")
|
||||
pid = getattr(process, "pid", None)
|
||||
try:
|
||||
return int(pid) if pid is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
async def connect_to_server(self, mcp_server_config: dict, name: str) -> None:
|
||||
"""Connect to MCP server
|
||||
|
||||
If `url` parameter exists:
|
||||
1. When transport is specified as `streamable_http`, use Streamable HTTP connection.
|
||||
2. When transport is specified as `sse`, use SSE connection.
|
||||
3. If not specified, default to SSE connection to MCP service.
|
||||
|
||||
Args:
|
||||
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server
|
||||
|
||||
"""
|
||||
# Store config for reconnection
|
||||
self._mcp_server_config = mcp_server_config
|
||||
self._server_name = name
|
||||
self.process_pid = None
|
||||
|
||||
cfg = _prepare_config(mcp_server_config.copy())
|
||||
|
||||
def logging_callback(
|
||||
msg: str | mcp.types.LoggingMessageNotificationParams,
|
||||
) -> None:
|
||||
# Handle MCP service error logs
|
||||
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
|
||||
if msg.level in ("warning", "error", "critical", "alert", "emergency"):
|
||||
log_msg = f"[{msg.level.upper()}] {msg.data!s}"
|
||||
self.server_errlogs.append(log_msg)
|
||||
|
||||
if "url" in cfg:
|
||||
success, error_msg = await _quick_test_mcp_connection(cfg)
|
||||
if not success:
|
||||
raise Exception(error_msg)
|
||||
|
||||
if "transport" in cfg:
|
||||
transport_type = cfg["transport"]
|
||||
elif "type" in cfg:
|
||||
transport_type = cfg["type"]
|
||||
else:
|
||||
raise Exception("MCP connection config missing transport or type field")
|
||||
|
||||
if transport_type != "streamable_http":
|
||||
# SSE transport method
|
||||
self._streams_context = sse_client(
|
||||
url=cfg["url"],
|
||||
headers=cfg.get("headers", {}),
|
||||
timeout=cfg.get("timeout", 5),
|
||||
sse_read_timeout=cfg.get("sse_read_timeout", 60 * 5),
|
||||
)
|
||||
streams = await self.exit_stack.enter_async_context(
|
||||
self._streams_context,
|
||||
)
|
||||
|
||||
# Create a new client session
|
||||
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60))
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(
|
||||
*streams,
|
||||
read_timeout_seconds=read_timeout,
|
||||
logging_callback=logging_callback, # type: ignore
|
||||
),
|
||||
)
|
||||
else:
|
||||
timeout = timedelta(seconds=cfg.get("timeout", 30))
|
||||
sse_read_timeout = timedelta(
|
||||
seconds=cfg.get("sse_read_timeout", 60 * 5),
|
||||
)
|
||||
self._streams_context = streamablehttp_client(
|
||||
url=cfg["url"],
|
||||
headers=cfg.get("headers", {}),
|
||||
timeout=timeout,
|
||||
sse_read_timeout=sse_read_timeout,
|
||||
terminate_on_close=cfg.get("terminate_on_close", True),
|
||||
)
|
||||
read_s, write_s, _ = await self.exit_stack.enter_async_context(
|
||||
self._streams_context,
|
||||
)
|
||||
|
||||
# Create a new client session
|
||||
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60))
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(
|
||||
read_stream=read_s,
|
||||
write_stream=write_s,
|
||||
read_timeout_seconds=read_timeout,
|
||||
logging_callback=logging_callback, # type: ignore
|
||||
),
|
||||
)
|
||||
|
||||
else:
|
||||
cfg = _prepare_stdio_env(cfg)
|
||||
server_params = mcp.StdioServerParameters(
|
||||
**cfg,
|
||||
)
|
||||
|
||||
def callback(msg: str | mcp.types.LoggingMessageNotificationParams) -> None:
|
||||
# Handle MCP service error logs
|
||||
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
|
||||
if msg.level in (
|
||||
"warning",
|
||||
"error",
|
||||
"critical",
|
||||
"alert",
|
||||
"emergency",
|
||||
):
|
||||
log_msg = f"[{msg.level.upper()}] {msg.data!s}"
|
||||
self.server_errlogs.append(log_msg)
|
||||
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
mcp.stdio_client(
|
||||
server_params,
|
||||
errlog=LogPipe(
|
||||
level=logging.INFO,
|
||||
logger=logger,
|
||||
identifier=f"MCPServer-{name}",
|
||||
callback=callback,
|
||||
), # type: ignore
|
||||
),
|
||||
)
|
||||
self.process_pid = self._extract_stdio_process_pid(self._streams_context)
|
||||
|
||||
# Create a new client session
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(*stdio_transport),
|
||||
)
|
||||
await self.session.initialize()
|
||||
|
||||
async def list_tools_and_save(self) -> mcp.ListToolsResult:
|
||||
"""List all tools from the server and save them to self.tools"""
|
||||
if not self.session:
|
||||
raise Exception("MCP Client is not initialized")
|
||||
response = await self.session.list_tools()
|
||||
self.tools = response.tools
|
||||
return response
|
||||
|
||||
async def _reconnect(self) -> None:
|
||||
"""Reconnect to the MCP server using the stored configuration.
|
||||
|
||||
Uses asyncio.Lock to ensure thread-safe reconnection in concurrent environments.
|
||||
|
||||
Raises:
|
||||
Exception: raised when reconnection fails
|
||||
"""
|
||||
async with self._reconnect_lock:
|
||||
# Check if already reconnecting (useful for logging)
|
||||
if self._reconnecting:
|
||||
logger.debug(
|
||||
f"MCP Client {self._server_name} is already reconnecting, skipping"
|
||||
)
|
||||
return
|
||||
|
||||
if not self._mcp_server_config or not self._server_name:
|
||||
raise Exception("Cannot reconnect: missing connection configuration")
|
||||
|
||||
self._reconnecting = True
|
||||
try:
|
||||
logger.info(
|
||||
f"Attempting to reconnect to MCP server {self._server_name}..."
|
||||
)
|
||||
|
||||
# Save old exit_stack for later cleanup (don't close it now to avoid cancel scope issues)
|
||||
if self.exit_stack:
|
||||
self._old_exit_stacks.append(self.exit_stack)
|
||||
|
||||
# Mark old session as invalid
|
||||
self.session = None
|
||||
|
||||
# Create new exit stack for new connection
|
||||
self.exit_stack = AsyncExitStack()
|
||||
|
||||
# Reconnect using stored config
|
||||
await self.connect_to_server(self._mcp_server_config, self._server_name)
|
||||
await self.list_tools_and_save()
|
||||
|
||||
logger.info(
|
||||
f"Successfully reconnected to MCP server {self._server_name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to reconnect to MCP server {self._server_name}: {e}"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
self._reconnecting = False
|
||||
|
||||
async def call_tool_with_reconnect(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict,
|
||||
read_timeout_seconds: timedelta,
|
||||
) -> mcp.types.CallToolResult:
|
||||
"""Call MCP tool with automatic reconnection on failure, max 2 retries.
|
||||
|
||||
Args:
|
||||
tool_name: tool name
|
||||
arguments: tool arguments
|
||||
read_timeout_seconds: read timeout
|
||||
|
||||
Returns:
|
||||
MCP tool call result
|
||||
|
||||
Raises:
|
||||
ValueError: MCP session is not available
|
||||
anyio.ClosedResourceError: raised after reconnection failure
|
||||
"""
|
||||
|
||||
@retry(
|
||||
retry=retry_if_exception_type(anyio.ClosedResourceError),
|
||||
stop=stop_after_attempt(2),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=3),
|
||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
async def _call_with_retry():
|
||||
if not self.session:
|
||||
raise ValueError("MCP session is not available for MCP function tools.")
|
||||
|
||||
try:
|
||||
return await self.session.call_tool(
|
||||
name=tool_name,
|
||||
arguments=arguments,
|
||||
read_timeout_seconds=read_timeout_seconds,
|
||||
)
|
||||
except anyio.ClosedResourceError:
|
||||
logger.warning(
|
||||
f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect..."
|
||||
)
|
||||
# Attempt to reconnect
|
||||
await self._reconnect()
|
||||
# Reraise the exception to trigger tenacity retry
|
||||
raise
|
||||
|
||||
return await _call_with_retry()
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up resources including old exit stacks from reconnections"""
|
||||
# Close current exit stack
|
||||
try:
|
||||
await self.exit_stack.aclose()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing current exit stack: {e}")
|
||||
|
||||
# Don't close old exit stacks as they may be in different task contexts
|
||||
# They will be garbage collected naturally
|
||||
# Just clear the list to release references
|
||||
self._old_exit_stacks.clear()
|
||||
|
||||
# Set running_event first to unblock any waiting tasks
|
||||
self.running_event.set()
|
||||
self.process_pid = None
|
||||
@@ -1,55 +0,0 @@
|
||||
"""MCP configuration management."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
|
||||
|
||||
|
||||
def get_mcp_config_path() -> str:
|
||||
"""Get the path to the MCP configuration file."""
|
||||
data_dir = get_astrbot_data_path()
|
||||
return os.path.join(data_dir, "mcp_server.json")
|
||||
|
||||
|
||||
def load_mcp_config() -> dict:
|
||||
"""Load MCP configuration from file.
|
||||
|
||||
Returns:
|
||||
MCP configuration dict. If file doesn't exist, returns default config.
|
||||
|
||||
"""
|
||||
config_path = get_mcp_config_path()
|
||||
if not os.path.exists(config_path):
|
||||
# Create default config if not exists
|
||||
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
|
||||
return DEFAULT_MCP_CONFIG
|
||||
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return DEFAULT_MCP_CONFIG
|
||||
|
||||
|
||||
def save_mcp_config(config: dict) -> bool:
|
||||
"""Save MCP configuration to file.
|
||||
|
||||
Args:
|
||||
config: MCP configuration dict to save.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
|
||||
"""
|
||||
config_path = get_mcp_config_path()
|
||||
try:
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=4)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -1,40 +0,0 @@
|
||||
"""MCP tool wrapper."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Generic
|
||||
|
||||
from astrbot._internal.tools.base import FunctionTool
|
||||
from astrbot.core.agent.run_context import ContextWrapper, TContext
|
||||
|
||||
from .client import MCPClient
|
||||
|
||||
try:
|
||||
import mcp
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
mcp = None # type: ignore
|
||||
|
||||
|
||||
class MCPTool(FunctionTool, Generic[TContext]):
|
||||
"""A function tool that calls an MCP service."""
|
||||
|
||||
def __init__(
|
||||
self, mcp_tool: mcp.Tool, mcp_client: MCPClient, mcp_server_name: str, **kwargs
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=mcp_tool.name,
|
||||
description=mcp_tool.description or "",
|
||||
parameters=mcp_tool.inputSchema,
|
||||
)
|
||||
self.mcp_tool = mcp_tool
|
||||
self.mcp_client = mcp_client
|
||||
self.mcp_server_name = mcp_server_name
|
||||
self.source = "mcp"
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[TContext], **kwargs
|
||||
) -> mcp.types.CallToolResult:
|
||||
return await self.mcp_client.call_tool_with_reconnect(
|
||||
tool_name=self.mcp_tool.name,
|
||||
arguments=kwargs,
|
||||
read_timeout_seconds=timedelta(seconds=context.tool_call_timeout),
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
"""AstrBot internal skills module.
|
||||
|
||||
This module provides the skill management system for AstrBot, including:
|
||||
- SkillManager: Manages skill lifecycle (install, activate, delete, etc.)
|
||||
- SkillInfo: Dataclass representing skill metadata
|
||||
- build_skills_prompt: Builds the system prompt section for skills
|
||||
- SkillToToolConverter: Converts skills with input_schema to FunctionTool
|
||||
"""
|
||||
|
||||
from .manager import (
|
||||
DEFAULT_SKILLS_CONFIG,
|
||||
SANDBOX_SKILLS_CACHE_FILENAME,
|
||||
SANDBOX_SKILLS_ROOT,
|
||||
SANDBOX_WORKSPACE_ROOT,
|
||||
SKILLS_CONFIG_FILENAME,
|
||||
SkillInfo,
|
||||
SkillManager,
|
||||
build_skills_prompt,
|
||||
)
|
||||
from .parser import parse_frontmatter, parse_skill_markdown
|
||||
from .to_tool import SkillToToolConverter
|
||||
|
||||
__all__ = [
|
||||
# Constants
|
||||
"DEFAULT_SKILLS_CONFIG",
|
||||
"SANDBOX_SKILLS_CACHE_FILENAME",
|
||||
"SANDBOX_SKILLS_ROOT",
|
||||
"SANDBOX_WORKSPACE_ROOT",
|
||||
"SKILLS_CONFIG_FILENAME",
|
||||
"SkillInfo",
|
||||
"SkillManager",
|
||||
"SkillToToolConverter",
|
||||
"build_skills_prompt",
|
||||
# Parser
|
||||
"parse_frontmatter",
|
||||
"parse_skill_markdown",
|
||||
]
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Skill loader - loads skills from filesystem."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .manager import SkillInfo, _normalize_skill_markdown_path, _parse_frontmatter
|
||||
|
||||
|
||||
def load_skill(skill_name: str, skills_root: Path) -> SkillInfo | None:
|
||||
"""Load a single skill by name from the skills root directory.
|
||||
|
||||
Args:
|
||||
skill_name: The name of the skill to load
|
||||
skills_root: Path to the skills root directory
|
||||
|
||||
Returns:
|
||||
SkillInfo if the skill exists and has a valid SKILL.md, None otherwise
|
||||
"""
|
||||
skill_dir = skills_root / skill_name
|
||||
if not skill_dir.is_dir():
|
||||
return None
|
||||
|
||||
skill_md = _normalize_skill_markdown_path(skill_dir)
|
||||
if skill_md is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
meta = _parse_frontmatter(content)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
description = meta.get("description", "")
|
||||
if not isinstance(description, str):
|
||||
description = ""
|
||||
description = description.strip()
|
||||
|
||||
input_schema = meta.get("input_schema")
|
||||
output_schema = meta.get("output_schema")
|
||||
|
||||
return SkillInfo(
|
||||
name=skill_name,
|
||||
description=description,
|
||||
path=str(skill_md).replace("\\", "/"),
|
||||
active=True,
|
||||
source_type="local_only",
|
||||
source_label="local",
|
||||
local_exists=True,
|
||||
sandbox_exists=False,
|
||||
input_schema=input_schema,
|
||||
output_schema=output_schema,
|
||||
)
|
||||
|
||||
|
||||
def load_all_skills(skills_root: Path) -> list[SkillInfo]:
|
||||
"""Load all skills from the skills root directory.
|
||||
|
||||
Args:
|
||||
skills_root: Path to the skills root directory
|
||||
|
||||
Returns:
|
||||
List of SkillInfo objects for all valid skills
|
||||
"""
|
||||
skills: list[SkillInfo] = []
|
||||
|
||||
if not skills_root.is_dir():
|
||||
return skills
|
||||
|
||||
for entry in sorted(skills_root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
|
||||
skill_name = entry.name
|
||||
skill_info = load_skill(skill_name, skills_root)
|
||||
if skill_info is not None:
|
||||
skills.append(skill_info)
|
||||
|
||||
return skills
|
||||
@@ -1,617 +0,0 @@
|
||||
"""Skill manager - manages skill lifecycle (install, activate, delete, etc.)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import yaml
|
||||
|
||||
from astrbot.core.utils.astrbot_path import AstrbotPaths, astrbot_paths
|
||||
|
||||
SKILLS_CONFIG_FILENAME = "skills.json"
|
||||
SANDBOX_SKILLS_CACHE_FILENAME = "sandbox_skills_cache.json"
|
||||
DEFAULT_SKILLS_CONFIG: dict[str, dict] = {"skills": {}}
|
||||
SANDBOX_SKILLS_ROOT = "skills"
|
||||
SANDBOX_WORKSPACE_ROOT = "/workspace"
|
||||
_SANDBOX_SKILLS_CACHE_VERSION = 1
|
||||
|
||||
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
def _default_sandbox_skill_path(name: str) -> str:
|
||||
return f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{name}/SKILL.md"
|
||||
|
||||
|
||||
def _normalize_cached_sandbox_skill_path(name: str, path: str) -> str:
|
||||
normalized = str(path or "").strip().replace("\\", "/")
|
||||
if not normalized:
|
||||
return _default_sandbox_skill_path(name)
|
||||
|
||||
pure_path = PurePosixPath(normalized)
|
||||
if ".." in pure_path.parts:
|
||||
return _default_sandbox_skill_path(name)
|
||||
|
||||
if pure_path.name != "SKILL.md":
|
||||
return _default_sandbox_skill_path(name)
|
||||
|
||||
if pure_path.parent.name != name:
|
||||
return _default_sandbox_skill_path(name)
|
||||
|
||||
return str(pure_path)
|
||||
|
||||
|
||||
def _is_ignored_zip_entry(name: str) -> bool:
|
||||
parts = PurePosixPath(name).parts
|
||||
if not parts:
|
||||
return True
|
||||
return parts[0] == "__MACOSX"
|
||||
|
||||
|
||||
def _normalize_skill_markdown_path(skill_dir: Path) -> Path | None:
|
||||
"""Return the canonical `SKILL.md` path for a skill directory.
|
||||
|
||||
If only legacy `skill.md` exists, it is renamed to `SKILL.md` in-place.
|
||||
"""
|
||||
canonical = skill_dir / "SKILL.md"
|
||||
entries = set()
|
||||
if skill_dir.exists():
|
||||
entries = {entry.name for entry in skill_dir.iterdir()}
|
||||
if "SKILL.md" in entries:
|
||||
return canonical
|
||||
legacy = skill_dir / "skill.md"
|
||||
if "skill.md" not in entries:
|
||||
return None
|
||||
try:
|
||||
tmp = skill_dir / f".{uuid.uuid4().hex}.tmp_skill_md"
|
||||
legacy.rename(tmp)
|
||||
tmp.rename(canonical)
|
||||
except OSError:
|
||||
return legacy
|
||||
return canonical
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillInfo:
|
||||
name: str
|
||||
description: str
|
||||
path: str
|
||||
active: bool
|
||||
source_type: str = "local_only"
|
||||
source_label: str = "local"
|
||||
local_exists: bool = True
|
||||
sandbox_exists: bool = False
|
||||
input_schema: dict | None = None
|
||||
output_schema: dict | None = None
|
||||
|
||||
|
||||
def _parse_frontmatter(text: str) -> dict:
|
||||
"""Extract metadata from YAML frontmatter.
|
||||
|
||||
Expects the standard SKILL.md format used by OpenAI Codex CLI and
|
||||
Anthropic Claude Skills::
|
||||
|
||||
---
|
||||
name: my-skill
|
||||
description: What this skill does and when to use it.
|
||||
input_schema: ...
|
||||
output_schema: ...
|
||||
---
|
||||
"""
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return {}
|
||||
end_idx = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
end_idx = i
|
||||
break
|
||||
if end_idx is None:
|
||||
return {}
|
||||
|
||||
frontmatter = "\n".join(lines[1:end_idx])
|
||||
try:
|
||||
payload = yaml.safe_load(frontmatter) or {}
|
||||
except yaml.YAMLError:
|
||||
return {}
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
# Regex for sanitizing paths used in prompt examples — only allow
|
||||
# safe path characters to prevent prompt injection via crafted skill paths.
|
||||
_SAFE_PATH_RE = re.compile(r"[^\w./ ,()'\-]", re.UNICODE)
|
||||
_WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:(?:/|\\)")
|
||||
_WINDOWS_UNC_PATH_RE = re.compile(r"^(//|\\\\)[^/\\]+[/\\][^/\\]+")
|
||||
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1F\x7F]")
|
||||
|
||||
|
||||
def _is_windows_prompt_path(path: str) -> bool:
|
||||
if os.name != "nt":
|
||||
return False
|
||||
return bool(_WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path))
|
||||
|
||||
|
||||
def _sanitize_prompt_path_for_prompt(path: str) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
|
||||
if _WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path):
|
||||
path = path.replace("\\", "/")
|
||||
|
||||
drive_prefix = ""
|
||||
if _WINDOWS_DRIVE_PATH_RE.match(path):
|
||||
drive_prefix = path[:2]
|
||||
path = path[2:]
|
||||
|
||||
path = path.replace("`", "")
|
||||
path = _CONTROL_CHARS_RE.sub("", path)
|
||||
sanitized = _SAFE_PATH_RE.sub("", path)
|
||||
return f"{drive_prefix}{sanitized}"
|
||||
|
||||
|
||||
def _sanitize_prompt_description(description: str) -> str:
|
||||
description = description.replace("`", "")
|
||||
description = _CONTROL_CHARS_RE.sub(" ", description)
|
||||
description = " ".join(description.split())
|
||||
return description
|
||||
|
||||
|
||||
def _sanitize_skill_display_name(name: str) -> str:
|
||||
if _SKILL_NAME_RE.fullmatch(name):
|
||||
return name
|
||||
return "<invalid_skill_name>"
|
||||
|
||||
|
||||
def _build_skill_read_command_example(path: str) -> str:
|
||||
if path == "<skills_root>/<skill_name>/SKILL.md":
|
||||
return f"cat {path}"
|
||||
if _is_windows_prompt_path(path):
|
||||
command = "type"
|
||||
path_arg = f'"{os.path.normpath(path)}"'
|
||||
else:
|
||||
command = "cat"
|
||||
path_arg = shlex.quote(path)
|
||||
return f"{command} {path_arg}"
|
||||
|
||||
|
||||
def build_skills_prompt(skills: list[SkillInfo]) -> str:
|
||||
"""Build the skills section of the system prompt.
|
||||
|
||||
Generates a markdown-formatted skill inventory for the LLM. Only
|
||||
``name`` and ``description`` are shown upfront; the LLM must read
|
||||
the full ``SKILL.md`` before execution (progressive disclosure).
|
||||
"""
|
||||
skills_lines: list[str] = []
|
||||
example_path = ""
|
||||
for skill in skills:
|
||||
display_name = _sanitize_skill_display_name(skill.name)
|
||||
|
||||
description = skill.description or "No description"
|
||||
if skill.source_type == "sandbox_only":
|
||||
description = _sanitize_prompt_description(description)
|
||||
if not description:
|
||||
description = "Read SKILL.md for details."
|
||||
|
||||
if skill.source_type == "sandbox_only":
|
||||
# Prefer the actual path from sandbox cache if available
|
||||
rendered_path = _sanitize_prompt_path_for_prompt(skill.path)
|
||||
if not rendered_path:
|
||||
rendered_path = _default_sandbox_skill_path(skill.name)
|
||||
else:
|
||||
rendered_path = _sanitize_prompt_path_for_prompt(skill.path)
|
||||
if not rendered_path:
|
||||
rendered_path = "<skills_root>/<skill_name>/SKILL.md"
|
||||
|
||||
entry = f"- **{display_name}**: {description}\n File: `{rendered_path}`"
|
||||
if skill.input_schema:
|
||||
entry += f"\n Input Schema: {json.dumps(skill.input_schema, ensure_ascii=False)}"
|
||||
if skill.output_schema:
|
||||
entry += f"\n Output Schema: {json.dumps(skill.output_schema, ensure_ascii=False)}"
|
||||
skills_lines.append(entry)
|
||||
if not example_path:
|
||||
example_path = rendered_path
|
||||
skills_block = "\n".join(skills_lines)
|
||||
# Sanitize example_path — it may originate from sandbox cache (untrusted)
|
||||
if example_path == "<skills_root>/<skill_name>/SKILL.md":
|
||||
example_path = "<skills_root>/<skill_name>/SKILL.md"
|
||||
else:
|
||||
example_path = _sanitize_prompt_path_for_prompt(example_path)
|
||||
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
|
||||
example_command = _build_skill_read_command_example(example_path)
|
||||
|
||||
return (
|
||||
"## Skills\n\n"
|
||||
"You have specialized skills — reusable instruction bundles stored "
|
||||
"in `SKILL.md` files. Each skill has a **name** and a **description** "
|
||||
"that tells you what it does and when to use it.\n\n"
|
||||
"### Available skills\n\n"
|
||||
f"{skills_block}\n\n"
|
||||
"### Skill rules\n\n"
|
||||
"1. **Discovery** — The list above is the complete skill inventory "
|
||||
"for this session. Full instructions are in the referenced "
|
||||
"`SKILL.md` file.\n"
|
||||
"2. **When to trigger** — Use a skill if the user names it "
|
||||
"explicitly, or if the task clearly matches the skill's description. "
|
||||
"*Never silently skip a matching skill* — either use it or briefly "
|
||||
"explain why you chose not to.\n"
|
||||
"3. **Mandatory grounding** — Before executing any skill you MUST "
|
||||
"first read its `SKILL.md` by running a shell command compatible "
|
||||
"with the current runtime shell and using the **absolute path** "
|
||||
f"shown above (e.g. `{example_command}`). "
|
||||
"Never rely on memory or assumptions about a skill's content.\n"
|
||||
"4. **Progressive disclosure** — Load only what is directly "
|
||||
"referenced from `SKILL.md`:\n"
|
||||
" - If `scripts/` exist, prefer running or patching them over "
|
||||
"rewriting code from scratch.\n"
|
||||
" - If `assets/` or templates exist, reuse them.\n"
|
||||
" - Do NOT bulk-load every file in the skill directory.\n"
|
||||
"5. **Coordination** — When multiple skills apply, pick the minimal "
|
||||
"set needed. Announce which skill(s) you are using and why "
|
||||
"(one short line). Prefer `astrbot_*` tools when running skill "
|
||||
"scripts.\n"
|
||||
"6. **Context hygiene** — Avoid deep reference chasing; open only "
|
||||
"files that are directly linked from `SKILL.md`.\n"
|
||||
"7. **Failure handling** — If a skill cannot be applied, state the "
|
||||
"issue clearly and continue with the best alternative.\n"
|
||||
)
|
||||
|
||||
|
||||
class SkillManager:
|
||||
def __init__(
|
||||
self,
|
||||
skills_root: str | None = None,
|
||||
astrbot_paths: AstrbotPaths = astrbot_paths,
|
||||
) -> None:
|
||||
self.astrbot_paths = astrbot_paths
|
||||
self.skills_root = skills_root or str(self.astrbot_paths.skills)
|
||||
self.config_path = str(self.astrbot_paths.config / SKILLS_CONFIG_FILENAME)
|
||||
self.sandbox_skills_cache_path = str(
|
||||
self.astrbot_paths.data / SANDBOX_SKILLS_CACHE_FILENAME
|
||||
)
|
||||
os.makedirs(self.skills_root, exist_ok=True)
|
||||
|
||||
def _load_config(self) -> dict:
|
||||
if not os.path.exists(self.config_path):
|
||||
self._save_config(DEFAULT_SKILLS_CONFIG.copy())
|
||||
return DEFAULT_SKILLS_CONFIG.copy()
|
||||
with open(self.config_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict) or "skills" not in data:
|
||||
return DEFAULT_SKILLS_CONFIG.copy()
|
||||
return data
|
||||
|
||||
def _save_config(self, config: dict) -> None:
|
||||
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
||||
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=4)
|
||||
|
||||
def _load_sandbox_skills_cache(self) -> dict:
|
||||
if not os.path.exists(self.sandbox_skills_cache_path):
|
||||
return {"version": _SANDBOX_SKILLS_CACHE_VERSION, "skills": []}
|
||||
try:
|
||||
with open(self.sandbox_skills_cache_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
return {"version": _SANDBOX_SKILLS_CACHE_VERSION, "skills": []}
|
||||
skills = data.get("skills", [])
|
||||
if not isinstance(skills, list):
|
||||
skills = []
|
||||
return {
|
||||
"version": int(data.get("version", _SANDBOX_SKILLS_CACHE_VERSION)),
|
||||
"skills": skills,
|
||||
"updated_at": data.get("updated_at"),
|
||||
}
|
||||
except Exception:
|
||||
return {"version": _SANDBOX_SKILLS_CACHE_VERSION, "skills": []}
|
||||
|
||||
def _save_sandbox_skills_cache(self, cache: dict) -> None:
|
||||
cache["version"] = _SANDBOX_SKILLS_CACHE_VERSION
|
||||
cache["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
os.makedirs(os.path.dirname(self.sandbox_skills_cache_path), exist_ok=True)
|
||||
with open(self.sandbox_skills_cache_path, "w", encoding="utf-8") as f:
|
||||
json.dump(cache, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def set_sandbox_skills_cache(self, skills: list[dict]) -> None:
|
||||
"""Persist sandbox skill metadata discovered from runtime side."""
|
||||
deduped: dict[str, dict[str, str]] = {}
|
||||
for item in skills:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name", "")).strip()
|
||||
if not name or not _SKILL_NAME_RE.match(name):
|
||||
continue
|
||||
description = str(item.get("description", "") or "")
|
||||
path = _normalize_cached_sandbox_skill_path(
|
||||
name, str(item.get("path", "") or "")
|
||||
)
|
||||
deduped[name] = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"path": path,
|
||||
}
|
||||
cache = {
|
||||
"version": _SANDBOX_SKILLS_CACHE_VERSION,
|
||||
"skills": [deduped[name] for name in sorted(deduped)],
|
||||
}
|
||||
self._save_sandbox_skills_cache(cache)
|
||||
|
||||
def get_sandbox_skills_cache_status(self) -> dict[str, object]:
|
||||
cache = self._load_sandbox_skills_cache()
|
||||
skills = cache.get("skills", [])
|
||||
count = len(skills) if isinstance(skills, list) else 0
|
||||
return {
|
||||
"exists": os.path.exists(self.sandbox_skills_cache_path),
|
||||
"ready": count > 0,
|
||||
"count": count,
|
||||
"updated_at": cache.get("updated_at"),
|
||||
}
|
||||
|
||||
def list_skills(
|
||||
self,
|
||||
*,
|
||||
active_only: bool = False,
|
||||
runtime: str = "local",
|
||||
show_sandbox_path: bool = True,
|
||||
) -> list[SkillInfo]:
|
||||
"""List all skills.
|
||||
|
||||
show_sandbox_path: If True and runtime is "sandbox",
|
||||
return the path as it would appear in the sandbox environment,
|
||||
otherwise return the local filesystem path.
|
||||
"""
|
||||
config = self._load_config()
|
||||
skill_configs = config.get("skills", {})
|
||||
modified = False
|
||||
skills_by_name: dict[str, SkillInfo] = {}
|
||||
|
||||
sandbox_cached_paths: dict[str, str] = {}
|
||||
sandbox_cached_descriptions: dict[str, str] = {}
|
||||
cache_for_paths = self._load_sandbox_skills_cache()
|
||||
for item in cache_for_paths.get("skills", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name", "") or "").strip()
|
||||
path = _normalize_cached_sandbox_skill_path(
|
||||
name, str(item.get("path", "") or "")
|
||||
)
|
||||
if not name or not _SKILL_NAME_RE.match(name):
|
||||
continue
|
||||
sandbox_cached_descriptions[name] = str(item.get("description", "") or "")
|
||||
sandbox_cached_paths[name] = path
|
||||
|
||||
for entry in sorted(Path(self.skills_root).iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
skill_name = entry.name
|
||||
skill_md = _normalize_skill_markdown_path(entry)
|
||||
if skill_md is None:
|
||||
continue
|
||||
active = skill_configs.get(skill_name, {}).get("active", True)
|
||||
if skill_name not in skill_configs:
|
||||
skill_configs[skill_name] = {"active": active}
|
||||
modified = True
|
||||
if active_only and not active:
|
||||
continue
|
||||
description = ""
|
||||
input_schema = None
|
||||
output_schema = None
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
meta = _parse_frontmatter(content)
|
||||
description = meta.get("description", "")
|
||||
if not isinstance(description, str):
|
||||
description = ""
|
||||
description = description.strip()
|
||||
input_schema = meta.get("input_schema")
|
||||
output_schema = meta.get("output_schema")
|
||||
except Exception:
|
||||
description = ""
|
||||
sandbox_exists = (
|
||||
runtime == "sandbox" and skill_name in sandbox_cached_descriptions
|
||||
)
|
||||
source_type = "both" if sandbox_exists else "local_only"
|
||||
source_label = "synced" if sandbox_exists else "local"
|
||||
if runtime == "sandbox" and show_sandbox_path:
|
||||
path_str = sandbox_cached_paths.get(
|
||||
skill_name
|
||||
) or _default_sandbox_skill_path(skill_name)
|
||||
else:
|
||||
path_str = str(skill_md)
|
||||
path_str = path_str.replace("\\", "/")
|
||||
skills_by_name[skill_name] = SkillInfo(
|
||||
name=skill_name,
|
||||
description=description,
|
||||
path=path_str,
|
||||
active=active,
|
||||
source_type=source_type,
|
||||
source_label=source_label,
|
||||
local_exists=True,
|
||||
sandbox_exists=sandbox_exists,
|
||||
input_schema=input_schema,
|
||||
output_schema=output_schema,
|
||||
)
|
||||
|
||||
if runtime == "sandbox":
|
||||
cache = self._load_sandbox_skills_cache()
|
||||
for item in cache.get("skills", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
skill_name = str(item.get("name", "")).strip()
|
||||
if (
|
||||
not skill_name
|
||||
or skill_name in skills_by_name
|
||||
or not _SKILL_NAME_RE.match(skill_name)
|
||||
):
|
||||
continue
|
||||
active = skill_configs.get(skill_name, {}).get("active", True)
|
||||
if skill_name not in skill_configs:
|
||||
skill_configs[skill_name] = {"active": active}
|
||||
modified = True
|
||||
if active_only and not active:
|
||||
continue
|
||||
description = sandbox_cached_descriptions.get(skill_name, "")
|
||||
# For sandbox_only skills, show_sandbox_path is implicitly True
|
||||
# since there is no local path to show. Always prefer the
|
||||
# actual path from sandbox cache.
|
||||
path_str = sandbox_cached_paths.get(
|
||||
skill_name
|
||||
) or _default_sandbox_skill_path(skill_name)
|
||||
skills_by_name[skill_name] = SkillInfo(
|
||||
name=skill_name,
|
||||
description=description,
|
||||
path=path_str.replace("\\", "/"),
|
||||
active=active,
|
||||
source_type="sandbox_only",
|
||||
source_label="sandbox_preset",
|
||||
local_exists=False,
|
||||
sandbox_exists=True,
|
||||
)
|
||||
|
||||
if modified:
|
||||
config["skills"] = skill_configs
|
||||
self._save_config(config)
|
||||
|
||||
return [skills_by_name[name] for name in sorted(skills_by_name)]
|
||||
|
||||
def is_sandbox_only_skill(self, name: str) -> bool:
|
||||
skill_dir = Path(self.skills_root) / name
|
||||
skill_md_exists = _normalize_skill_markdown_path(skill_dir) is not None
|
||||
if skill_md_exists:
|
||||
return False
|
||||
cache = self._load_sandbox_skills_cache()
|
||||
skills = cache.get("skills", [])
|
||||
if not isinstance(skills, list):
|
||||
return False
|
||||
for item in skills:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get("name", "")).strip() == name:
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_skill_active(self, name: str, active: bool) -> None:
|
||||
if self.is_sandbox_only_skill(name):
|
||||
raise PermissionError(
|
||||
"Sandbox preset skill cannot be enabled/disabled from local skill management."
|
||||
)
|
||||
config = self._load_config()
|
||||
config.setdefault("skills", {})
|
||||
config["skills"][name] = {"active": bool(active)}
|
||||
self._save_config(config)
|
||||
|
||||
def _remove_skill_from_sandbox_cache(self, name: str) -> None:
|
||||
cache = self._load_sandbox_skills_cache()
|
||||
skills = cache.get("skills", [])
|
||||
if not isinstance(skills, list):
|
||||
return
|
||||
|
||||
filtered = [
|
||||
item
|
||||
for item in skills
|
||||
if not (
|
||||
isinstance(item, dict) and str(item.get("name", "")).strip() == name
|
||||
)
|
||||
]
|
||||
|
||||
if len(filtered) != len(skills):
|
||||
cache["skills"] = filtered
|
||||
self._save_sandbox_skills_cache(cache)
|
||||
|
||||
def delete_skill(self, name: str) -> None:
|
||||
if self.is_sandbox_only_skill(name):
|
||||
raise PermissionError(
|
||||
"Sandbox preset skill cannot be deleted from local skill management."
|
||||
)
|
||||
|
||||
skill_dir = Path(self.skills_root) / name
|
||||
if skill_dir.exists():
|
||||
shutil.rmtree(skill_dir)
|
||||
|
||||
# Ensure UI consistency even when there is no active sandbox session
|
||||
# to refresh cache from runtime side.
|
||||
self._remove_skill_from_sandbox_cache(name)
|
||||
|
||||
config = self._load_config()
|
||||
if name in config.get("skills", {}):
|
||||
config["skills"].pop(name, None)
|
||||
self._save_config(config)
|
||||
|
||||
def install_skill_from_zip(self, zip_path: str, *, overwrite: bool = True) -> str:
|
||||
zip_path_obj = Path(zip_path)
|
||||
if not zip_path_obj.exists():
|
||||
raise FileNotFoundError(f"Zip file not found: {zip_path}")
|
||||
if not zipfile.is_zipfile(zip_path):
|
||||
raise ValueError("Uploaded file is not a valid zip archive.")
|
||||
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = [
|
||||
name
|
||||
for name in (entry.replace("\\", "/") for entry in zf.namelist())
|
||||
if name and not _is_ignored_zip_entry(name)
|
||||
]
|
||||
file_names = [name for name in names if name and not name.endswith("/")]
|
||||
if not file_names:
|
||||
raise ValueError("Zip archive is empty.")
|
||||
|
||||
top_dirs = {
|
||||
PurePosixPath(name).parts[0] for name in file_names if name.strip()
|
||||
}
|
||||
|
||||
if len(top_dirs) != 1:
|
||||
raise ValueError("Zip archive must contain a single top-level folder.")
|
||||
skill_name = next(iter(top_dirs))
|
||||
if skill_name in {".", "..", ""} or not _SKILL_NAME_RE.match(skill_name):
|
||||
raise ValueError("Invalid skill folder name.")
|
||||
|
||||
for name in names:
|
||||
if not name:
|
||||
continue
|
||||
if name.startswith("/") or re.match(r"^[A-Za-z]:", name):
|
||||
raise ValueError("Zip archive contains absolute paths.")
|
||||
parts = PurePosixPath(name).parts
|
||||
if ".." in parts:
|
||||
raise ValueError("Zip archive contains invalid relative paths.")
|
||||
if parts and parts[0] != skill_name:
|
||||
raise ValueError(
|
||||
"Zip archive contains unexpected top-level entries."
|
||||
)
|
||||
|
||||
if (
|
||||
f"{skill_name}/SKILL.md" not in file_names
|
||||
and f"{skill_name}/skill.md" not in file_names
|
||||
):
|
||||
raise ValueError("SKILL.md not found in the skill folder.")
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=str(astrbot_paths.temp)) as tmp_dir:
|
||||
for member in zf.infolist():
|
||||
member_name = member.filename.replace("\\", "/")
|
||||
if not member_name or _is_ignored_zip_entry(member_name):
|
||||
continue
|
||||
zf.extract(member, tmp_dir)
|
||||
src_dir = Path(tmp_dir) / skill_name
|
||||
_normalize_skill_markdown_path(src_dir)
|
||||
if not src_dir.exists():
|
||||
raise ValueError("Skill folder not found after extraction.")
|
||||
dest_dir = Path(self.skills_root) / skill_name
|
||||
if dest_dir.exists():
|
||||
if not overwrite:
|
||||
raise FileExistsError("Skill already exists.")
|
||||
shutil.rmtree(dest_dir)
|
||||
shutil.move(str(src_dir), str(dest_dir))
|
||||
|
||||
self.set_skill_active(skill_name, True)
|
||||
return skill_name
|
||||
@@ -1,81 +0,0 @@
|
||||
"""SKILL.md parser for extracting frontmatter and content."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> dict:
|
||||
"""Extract metadata from YAML frontmatter.
|
||||
|
||||
Expects the standard SKILL.md format used by OpenAI Codex CLI and
|
||||
Anthropic Claude Skills::
|
||||
|
||||
---
|
||||
name: my-skill
|
||||
description: What this skill does and when to use it.
|
||||
input_schema: ...
|
||||
output_schema: ...
|
||||
---
|
||||
"""
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return {}
|
||||
end_idx = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
end_idx = i
|
||||
break
|
||||
if end_idx is None:
|
||||
return {}
|
||||
|
||||
frontmatter = "\n".join(lines[1:end_idx])
|
||||
try:
|
||||
payload = yaml.safe_load(frontmatter) or {}
|
||||
except yaml.YAMLError:
|
||||
return {}
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def parse_skill_markdown(path: Path) -> dict:
|
||||
"""Parse a SKILL.md file and return frontmatter + content.
|
||||
|
||||
Args:
|
||||
path: Path to the SKILL.md file
|
||||
|
||||
Returns:
|
||||
dict with keys: frontmatter (dict), content (str)
|
||||
"""
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return {"frontmatter": {}, "content": ""}
|
||||
|
||||
frontmatter = parse_frontmatter(text)
|
||||
|
||||
# Extract content after frontmatter
|
||||
if text.startswith("---"):
|
||||
lines = text.splitlines()
|
||||
end_idx = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
end_idx = i
|
||||
break
|
||||
if end_idx is not None:
|
||||
content = "\n".join(lines[end_idx + 1 :]).strip()
|
||||
else:
|
||||
content = ""
|
||||
else:
|
||||
content = text
|
||||
|
||||
return {
|
||||
"frontmatter": frontmatter,
|
||||
"content": content,
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
"""Skills prompt builder - builds the system prompt section for skills."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
|
||||
from .manager import SkillInfo
|
||||
|
||||
# Regex for sanitizing paths used in prompt examples — only allow
|
||||
# safe path characters to prevent prompt injection via crafted skill paths.
|
||||
_SAFE_PATH_RE = re.compile(r"[^\w./ ,()'\-]", re.UNICODE)
|
||||
_WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:(?:/|\\)")
|
||||
_WINDOWS_UNC_PATH_RE = re.compile(r"^(//|\\\\)[^/\\]+[/\\][^/\\]+")
|
||||
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1F\x7F]")
|
||||
|
||||
SANDBOX_WORKSPACE_ROOT = "/workspace"
|
||||
SANDBOX_SKILLS_ROOT = "skills"
|
||||
|
||||
|
||||
def _is_windows_prompt_path(path: str) -> bool:
|
||||
if os.name != "nt":
|
||||
return False
|
||||
return bool(_WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path))
|
||||
|
||||
|
||||
def _sanitize_prompt_path_for_prompt(path: str) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
|
||||
if _WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path):
|
||||
path = path.replace("\\", "/")
|
||||
|
||||
drive_prefix = ""
|
||||
if _WINDOWS_DRIVE_PATH_RE.match(path):
|
||||
drive_prefix = path[:2]
|
||||
path = path[2:]
|
||||
|
||||
path = path.replace("`", "")
|
||||
path = _CONTROL_CHARS_RE.sub("", path)
|
||||
sanitized = _SAFE_PATH_RE.sub("", path)
|
||||
return f"{drive_prefix}{sanitized}"
|
||||
|
||||
|
||||
def _sanitize_prompt_description(description: str) -> str:
|
||||
description = description.replace("`", "")
|
||||
description = _CONTROL_CHARS_RE.sub(" ", description)
|
||||
description = " ".join(description.split())
|
||||
return description
|
||||
|
||||
|
||||
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
def _sanitize_skill_display_name(name: str) -> str:
|
||||
if _SKILL_NAME_RE.fullmatch(name):
|
||||
return name
|
||||
return "<invalid_skill_name>"
|
||||
|
||||
|
||||
def _default_sandbox_skill_path(name: str) -> str:
|
||||
return f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{name}/SKILL.md"
|
||||
|
||||
|
||||
def _build_skill_read_command_example(path: str) -> str:
|
||||
if path == "<skills_root>/<skill_name>/SKILL.md":
|
||||
return f"cat {path}"
|
||||
if _is_windows_prompt_path(path):
|
||||
command = "type"
|
||||
path_arg = f'"{os.path.normpath(path)}"'
|
||||
else:
|
||||
command = "cat"
|
||||
path_arg = shlex.quote(path)
|
||||
return f"{command} {path_arg}"
|
||||
|
||||
|
||||
def build_skills_prompt(skills: list[SkillInfo]) -> str:
|
||||
"""Build the skills section of the system prompt.
|
||||
|
||||
Generates a markdown-formatted skill inventory for the LLM. Only
|
||||
``name`` and ``description`` are shown upfront; the LLM must read
|
||||
the full ``SKILL.md`` before execution (progressive disclosure).
|
||||
"""
|
||||
skills_lines: list[str] = []
|
||||
example_path = ""
|
||||
for skill in skills:
|
||||
display_name = _sanitize_skill_display_name(skill.name)
|
||||
|
||||
description = skill.description or "No description"
|
||||
if skill.source_type == "sandbox_only":
|
||||
description = _sanitize_prompt_description(description)
|
||||
if not description:
|
||||
description = "Read SKILL.md for details."
|
||||
|
||||
if skill.source_type == "sandbox_only":
|
||||
# Prefer the actual path from sandbox cache if available
|
||||
rendered_path = _sanitize_prompt_path_for_prompt(skill.path)
|
||||
if not rendered_path:
|
||||
rendered_path = _default_sandbox_skill_path(skill.name)
|
||||
else:
|
||||
rendered_path = _sanitize_prompt_path_for_prompt(skill.path)
|
||||
if not rendered_path:
|
||||
rendered_path = "<skills_root>/<skill_name>/SKILL.md"
|
||||
|
||||
entry = f"- **{display_name}**: {description}\n File: `{rendered_path}`"
|
||||
if skill.input_schema:
|
||||
entry += f"\n Input Schema: {json.dumps(skill.input_schema, ensure_ascii=False)}"
|
||||
if skill.output_schema:
|
||||
entry += f"\n Output Schema: {json.dumps(skill.output_schema, ensure_ascii=False)}"
|
||||
skills_lines.append(entry)
|
||||
if not example_path:
|
||||
example_path = rendered_path
|
||||
skills_block = "\n".join(skills_lines)
|
||||
# Sanitize example_path — it may originate from sandbox cache (untrusted)
|
||||
if example_path == "<skills_root>/<skill_name>/SKILL.md":
|
||||
example_path = "<skills_root>/<skill_name>/SKILL.md"
|
||||
else:
|
||||
example_path = _sanitize_prompt_path_for_prompt(example_path)
|
||||
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
|
||||
example_command = _build_skill_read_command_example(example_path)
|
||||
|
||||
return (
|
||||
"## Skills\n\n"
|
||||
"You have specialized skills — reusable instruction bundles stored "
|
||||
"in `SKILL.md` files. Each skill has a **name** and a **description** "
|
||||
"that tells you what it does and when to use it.\n\n"
|
||||
"### Available skills\n\n"
|
||||
f"{skills_block}\n\n"
|
||||
"### Skill rules\n\n"
|
||||
"1. **Discovery** — The list above is the complete skill inventory "
|
||||
"for this session. Full instructions are in the referenced "
|
||||
"`SKILL.md` file.\n"
|
||||
"2. **When to trigger** — Use a skill if the user names it "
|
||||
"explicitly, or if the task clearly matches the skill's description. "
|
||||
"*Never silently skip a matching skill* — either use it or briefly "
|
||||
"explain why you chose not to.\n"
|
||||
"3. **Mandatory grounding** — Before executing any skill you MUST "
|
||||
"first read its `SKILL.md` by running a shell command compatible "
|
||||
"with the current runtime shell and using the **absolute path** "
|
||||
f"shown above (e.g. `{example_command}`). "
|
||||
"Never rely on memory or assumptions about a skill's content.\n"
|
||||
"4. **Progressive disclosure** — Load only what is directly "
|
||||
"referenced from `SKILL.md`:\n"
|
||||
" - If `scripts/` exist, prefer running or patching them over "
|
||||
"rewriting code from scratch.\n"
|
||||
" - If `assets/` or templates exist, reuse them.\n"
|
||||
" - Do NOT bulk-load every file in the skill directory.\n"
|
||||
"5. **Coordination** — When multiple skills apply, pick the minimal "
|
||||
"set needed. Announce which skill(s) you are using and why "
|
||||
"(one short line). Prefer `astrbot_*` tools when running skill "
|
||||
"scripts.\n"
|
||||
"6. **Context hygiene** — Avoid deep reference chasing; open only "
|
||||
"files that are directly linked from `SKILL.md`.\n"
|
||||
"7. **Failure handling** — If a skill cannot be applied, state the "
|
||||
"issue clearly and continue with the best alternative.\n"
|
||||
)
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Skill to Tool converter - converts skills with input_schema to FunctionTool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from pydantic.dataclasses import dataclass
|
||||
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
from .manager import SkillInfo
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillToToolConverter:
|
||||
"""Converter that transforms skills with input_schema into FunctionTool instances.
|
||||
|
||||
This enables skills to be used as callable tools in the agent's function calling
|
||||
system, providing a structured way to invoke skills with parameters.
|
||||
"""
|
||||
|
||||
def can_convert(self, skill: SkillInfo) -> bool:
|
||||
"""Check if a skill can be converted to a FunctionTool.
|
||||
|
||||
Args:
|
||||
skill: The skill to check
|
||||
|
||||
Returns:
|
||||
True if the skill has an input_schema, False otherwise
|
||||
"""
|
||||
return skill.input_schema is not None
|
||||
|
||||
def convert(self, skill: SkillInfo) -> FunctionTool:
|
||||
"""Convert a skill to a FunctionTool.
|
||||
|
||||
Args:
|
||||
skill: The skill to convert. Must have an input_schema.
|
||||
|
||||
Returns:
|
||||
A FunctionTool with name=f"skill_{skill.name}", description, parameters,
|
||||
and a handler that executes the skill.
|
||||
|
||||
Raises:
|
||||
ValueError: If the skill does not have an input_schema
|
||||
"""
|
||||
if not self.can_convert(skill):
|
||||
raise ValueError(
|
||||
f"Skill '{skill.name}' cannot be converted to FunctionTool: "
|
||||
"no input_schema defined"
|
||||
)
|
||||
|
||||
tool_name = f"skill_{skill.name}"
|
||||
tool_description = skill.description or f"Skill: {skill.name}"
|
||||
|
||||
# Create a handler that executes the skill
|
||||
# The actual execution is delegated to the skill execution system
|
||||
async def skill_handler(
|
||||
context: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> str | None:
|
||||
# This is a placeholder handler that integrates with the skill system
|
||||
# The actual skill execution would happen here through the skill manager
|
||||
# For now, we return a message indicating the skill should be executed
|
||||
# through the standard skill execution flow
|
||||
return f"Skill '{skill.name}' execution requested with params: {kwargs}"
|
||||
|
||||
handler: Callable[..., Awaitable[str | None]] = skill_handler
|
||||
|
||||
return FunctionTool(
|
||||
name=tool_name,
|
||||
description=tool_description,
|
||||
parameters=skill.input_schema, # type: ignore[arg-type]
|
||||
handler=handler,
|
||||
handler_module_path="astrbot._internal.skills.to_tool",
|
||||
active=True,
|
||||
is_background_task=False,
|
||||
source="skill",
|
||||
)
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Internal tools module for AstrBot.
|
||||
|
||||
This module provides internal tool abstractions and registry functionality
|
||||
for the AstrBot framework.
|
||||
"""
|
||||
|
||||
from astrbot._internal.tools.base import FunctionTool, ToolExecResult
|
||||
|
||||
__all__ = ["FunctionTool", "ToolExecResult"]
|
||||
@@ -1,442 +0,0 @@
|
||||
"""Internal base tool definitions for AstrBot.
|
||||
|
||||
This module provides the core tool abstractions used throughout AstrBot,
|
||||
including tool schemas, callable function tools, and tool sets for
|
||||
managing multiple tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from typing import Any, Generic
|
||||
|
||||
import jsonschema
|
||||
import mcp
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic.dataclasses import dataclass
|
||||
|
||||
from astrbot.core.agent.run_context import ContextWrapper, TContext
|
||||
from astrbot.core.message.message_event_result import MessageEventResult
|
||||
|
||||
ParametersType = dict[str, Any]
|
||||
ToolExecResult = str | mcp.types.CallToolResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSchema:
|
||||
"""A class representing the schema of a tool for function calling.
|
||||
|
||||
ToolSchema defines the interface contract for a tool, including its
|
||||
name, description, and parameter specification in JSON Schema format.
|
||||
The parameters are validated against the JSON Schema Draft 2020-12.
|
||||
"""
|
||||
|
||||
name: str
|
||||
"""The name of the tool."""
|
||||
|
||||
description: str
|
||||
"""The description of the tool."""
|
||||
|
||||
parameters: ParametersType
|
||||
"""The parameters of the tool, in JSON Schema format."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_parameters(self) -> ToolSchema:
|
||||
"""Validate that parameters conform to JSON Schema Draft 2020-12."""
|
||||
jsonschema.validate(
|
||||
self.parameters, jsonschema.Draft202012Validator.META_SCHEMA
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@dataclass
|
||||
class FunctionTool(ToolSchema, Generic[TContext]):
|
||||
"""A callable tool, for function calling.
|
||||
|
||||
FunctionTool represents an executable tool that can be called by the LLM.
|
||||
It extends ToolSchema with a handler callable that performs the actual
|
||||
tool execution. The handler should be an async function.
|
||||
|
||||
Type Parameters:
|
||||
TContext: The context type for tool execution.
|
||||
|
||||
Attributes:
|
||||
handler: The async callable that implements the tool's functionality.
|
||||
handler_module_path: Module path of the handler function for serialization.
|
||||
active: Whether the tool is active and should be used.
|
||||
is_background_task: Whether this tool runs as a background task.
|
||||
source: Origin of the tool ('plugin', 'internal', or 'mcp').
|
||||
"""
|
||||
|
||||
handler: (
|
||||
Callable[..., Awaitable[str | None] | AsyncGenerator[MessageEventResult, None]]
|
||||
| None
|
||||
) = None
|
||||
"""A callable that implements the tool's functionality. It should be an async function."""
|
||||
|
||||
handler_module_path: str | None = None
|
||||
"""
|
||||
The module path of the handler function. This is empty when the origin is mcp.
|
||||
This field must be retained, as the handler will be wrapped in functools.partial during initialization,
|
||||
causing the handler's __module__ to be functools
|
||||
"""
|
||||
active: bool = True
|
||||
"""
|
||||
Whether the tool is active. This field is a special field for AstrBot.
|
||||
You can ignore it when integrating with other frameworks.
|
||||
"""
|
||||
is_background_task: bool = False
|
||||
"""
|
||||
Declare this tool as a background task. Background tasks return immediately
|
||||
with a task identifier while the real work continues asynchronously.
|
||||
"""
|
||||
source: str = "plugin"
|
||||
"""
|
||||
Origin of this tool: 'plugin' (from star plugins), 'internal' (AstrBot built-in),
|
||||
or 'mcp' (from MCP servers). Used by WebUI for display grouping.
|
||||
"""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})"
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[TContext], **kwargs: Any
|
||||
) -> ToolExecResult:
|
||||
"""Run the tool with the given arguments. The handler field has priority.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If no handler is set.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"FunctionTool.call() must be implemented by subclasses or set a handler."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSet:
|
||||
"""A set of function tools that can be used in function calling.
|
||||
|
||||
This class provides methods to add, remove, and retrieve tools, as well as
|
||||
convert the tools to different API formats (OpenAI, Anthropic, Google GenAI).
|
||||
|
||||
Tools can be added with add_tool(), which handles duplicate names by
|
||||
preferring active tools. The normalize() method sorts tools by name
|
||||
for deterministic serialization.
|
||||
"""
|
||||
|
||||
tools: list[FunctionTool] = Field(default_factory=list)
|
||||
|
||||
def empty(self) -> bool:
|
||||
"""Check if the tool set is empty."""
|
||||
return len(self.tools) == 0
|
||||
|
||||
def add_tool(self, tool: FunctionTool) -> None:
|
||||
"""Add a tool to the set.
|
||||
|
||||
If a tool with the same name already exists:
|
||||
- Prefer the one that is active (active=True)
|
||||
- If both have the same active state, use the new one (overwrite)
|
||||
"""
|
||||
for i, existing_tool in enumerate(self.tools):
|
||||
if existing_tool.name == tool.name:
|
||||
# Use getattr with a default of True for compatibility with tools
|
||||
# that may not define an `active` attribute (e.g., mocks).
|
||||
existing_active = bool(getattr(existing_tool, "active", True))
|
||||
new_active = bool(getattr(tool, "active", True))
|
||||
# Overwrite if new tool is active, or if existing tool is not active
|
||||
if new_active or not existing_active:
|
||||
self.tools[i] = tool
|
||||
return
|
||||
self.tools.append(tool)
|
||||
|
||||
def remove_tool(self, name: str) -> None:
|
||||
"""Remove a tool by its name."""
|
||||
self.tools = [tool for tool in self.tools if tool.name != name]
|
||||
|
||||
def normalize(self) -> None:
|
||||
"""Sort tools by name for deterministic serialization.
|
||||
|
||||
This ensures the serialized tool schema sent to the LLM is
|
||||
identical across requests regardless of registration/injection
|
||||
order, enabling LLM provider prefix cache hits.
|
||||
"""
|
||||
self.tools.sort(key=lambda t: t.name)
|
||||
|
||||
def get_tool(self, name: str) -> FunctionTool | None:
|
||||
"""Get a tool by its name."""
|
||||
for tool in self.tools:
|
||||
if tool.name == name:
|
||||
return tool
|
||||
return None
|
||||
|
||||
def get_light_tool_set(self) -> ToolSet:
|
||||
"""Return a light tool set with only name/description."""
|
||||
light_tools = []
|
||||
for tool in self.tools:
|
||||
if hasattr(tool, "active") and not tool.active:
|
||||
continue
|
||||
light_params = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}
|
||||
light_tools.append(
|
||||
FunctionTool(
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
parameters=light_params,
|
||||
handler=None,
|
||||
)
|
||||
)
|
||||
return ToolSet(light_tools)
|
||||
|
||||
def get_param_only_tool_set(self) -> ToolSet:
|
||||
"""Return a tool set with name/parameters only (no description)."""
|
||||
param_tools = []
|
||||
for tool in self.tools:
|
||||
if hasattr(tool, "active") and not tool.active:
|
||||
continue
|
||||
params = (
|
||||
copy.deepcopy(tool.parameters)
|
||||
if tool.parameters
|
||||
else {"type": "object", "properties": {}}
|
||||
)
|
||||
param_tools.append(
|
||||
FunctionTool(
|
||||
name=tool.name,
|
||||
description="",
|
||||
parameters=params,
|
||||
handler=None,
|
||||
)
|
||||
)
|
||||
return ToolSet(param_tools)
|
||||
|
||||
def add_func(
|
||||
self,
|
||||
name: str,
|
||||
func_args: list,
|
||||
desc: str,
|
||||
handler: Callable[..., Awaitable[Any]],
|
||||
) -> None:
|
||||
"""Add a function tool to the set.
|
||||
|
||||
.. deprecated:: 4.0.0
|
||||
Use add_tool() instead.
|
||||
"""
|
||||
params = {
|
||||
"type": "object", # hard-coded here
|
||||
"properties": {},
|
||||
}
|
||||
for param in func_args:
|
||||
params["properties"][param["name"]] = {
|
||||
"type": param["type"],
|
||||
"description": param["description"],
|
||||
}
|
||||
_func = FunctionTool(
|
||||
name=name,
|
||||
parameters=params,
|
||||
description=desc,
|
||||
handler=handler,
|
||||
)
|
||||
self.add_tool(_func)
|
||||
|
||||
def remove_func(self, name: str) -> None:
|
||||
"""Remove a function tool by its name.
|
||||
|
||||
.. deprecated:: 4.0.0
|
||||
Use remove_tool() instead.
|
||||
"""
|
||||
self.remove_tool(name)
|
||||
|
||||
def get_func(self, name: str) -> FunctionTool | None:
|
||||
"""Get all function tools.
|
||||
|
||||
.. deprecated:: 4.0.0
|
||||
Use get_tool() instead.
|
||||
"""
|
||||
return self.get_tool(name)
|
||||
|
||||
@property
|
||||
def func_list(self) -> list[FunctionTool]:
|
||||
"""Get the list of function tools."""
|
||||
return self.tools
|
||||
|
||||
def openai_schema(self, omit_empty_parameter_field: bool = False) -> list[dict]:
|
||||
"""Convert tools to OpenAI API function calling schema format."""
|
||||
result = []
|
||||
for tool in self.tools:
|
||||
func_def: dict[str, Any] = {
|
||||
"type": "function",
|
||||
"function": {"name": tool.name},
|
||||
}
|
||||
if tool.description:
|
||||
func_def["function"]["description"] = tool.description
|
||||
|
||||
if tool.parameters is not None:
|
||||
if (
|
||||
tool.parameters and tool.parameters.get("properties")
|
||||
) or not omit_empty_parameter_field:
|
||||
func_def["function"]["parameters"] = tool.parameters # type: ignore[index]
|
||||
|
||||
result.append(func_def)
|
||||
return result
|
||||
|
||||
def anthropic_schema(self) -> list[dict]:
|
||||
"""Convert tools to Anthropic API format."""
|
||||
result = []
|
||||
for tool in self.tools:
|
||||
input_schema = {"type": "object"}
|
||||
if tool.parameters:
|
||||
input_schema["properties"] = tool.parameters.get("properties", {})
|
||||
input_schema["required"] = tool.parameters.get("required", [])
|
||||
tool_def = {"name": tool.name, "input_schema": input_schema}
|
||||
if tool.description:
|
||||
tool_def["description"] = tool.description
|
||||
result.append(tool_def)
|
||||
return result
|
||||
|
||||
def google_schema(self) -> dict:
|
||||
"""Convert tools to Google GenAI API format."""
|
||||
|
||||
def convert_schema(schema: dict) -> dict:
|
||||
"""Convert schema to Gemini API format."""
|
||||
supported_types = {
|
||||
"string",
|
||||
"number",
|
||||
"integer",
|
||||
"boolean",
|
||||
"array",
|
||||
"object",
|
||||
"null",
|
||||
}
|
||||
supported_formats = {
|
||||
"string": {"enum", "date-time"},
|
||||
"integer": {"int32", "int64"},
|
||||
"number": {"float", "double"},
|
||||
}
|
||||
|
||||
if "anyOf" in schema:
|
||||
return {"anyOf": [convert_schema(s) for s in schema["anyOf"]]}
|
||||
|
||||
result = {}
|
||||
|
||||
# Avoid side effects by not modifying the original schema
|
||||
origin_type = schema.get("type")
|
||||
target_type = origin_type
|
||||
|
||||
# Compatibility fix: Gemini API expects 'type' to be a string (enum),
|
||||
# but standard JSON Schema (MCP) allows lists (e.g. ["string", "null"]).
|
||||
# We fallback to the first non-null type.
|
||||
if isinstance(origin_type, list):
|
||||
target_type = next((t for t in origin_type if t != "null"), "string")
|
||||
|
||||
if target_type in supported_types:
|
||||
result["type"] = target_type
|
||||
if "format" in schema and schema["format"] in supported_formats.get(
|
||||
result["type"],
|
||||
set(),
|
||||
):
|
||||
result["format"] = schema["format"]
|
||||
else:
|
||||
result["type"] = "null"
|
||||
|
||||
support_fields = {
|
||||
"title",
|
||||
"description",
|
||||
"enum",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"maxItems",
|
||||
"minItems",
|
||||
"nullable",
|
||||
"required",
|
||||
}
|
||||
result.update({k: schema[k] for k in support_fields if k in schema})
|
||||
|
||||
if "properties" in schema:
|
||||
properties = {}
|
||||
for key, value in schema["properties"].items():
|
||||
prop_value = convert_schema(value)
|
||||
if "default" in prop_value:
|
||||
del prop_value["default"]
|
||||
# see #5217
|
||||
if "additionalProperties" in prop_value:
|
||||
del prop_value["additionalProperties"]
|
||||
properties[key] = prop_value
|
||||
|
||||
if properties:
|
||||
result["properties"] = properties
|
||||
|
||||
if target_type == "array":
|
||||
items_schema = schema.get("items")
|
||||
if isinstance(items_schema, dict):
|
||||
result["items"] = convert_schema(items_schema)
|
||||
else:
|
||||
# Gemini requires array schemas to include an `items` schema.
|
||||
# JSON Schema allows omitting it, so fall back to a permissive
|
||||
# string item schema instead of emitting an invalid declaration.
|
||||
result["items"] = {"type": "string"}
|
||||
|
||||
return result
|
||||
|
||||
tools = []
|
||||
for tool in self.tools:
|
||||
d: dict[str, Any] = {"name": tool.name}
|
||||
if tool.description:
|
||||
d["description"] = tool.description
|
||||
if tool.parameters:
|
||||
d["parameters"] = convert_schema(tool.parameters)
|
||||
tools.append(d)
|
||||
|
||||
declarations = {}
|
||||
if tools:
|
||||
declarations["function_declarations"] = tools
|
||||
return declarations
|
||||
|
||||
def get_func_desc_openai_style(self, omit_empty_parameter_field: bool = False):
|
||||
"""Get OpenAI style function descriptions.
|
||||
|
||||
.. deprecated:: 4.0.0
|
||||
Use openai_schema() instead.
|
||||
"""
|
||||
return self.openai_schema(omit_empty_parameter_field)
|
||||
|
||||
def get_func_desc_anthropic_style(self):
|
||||
"""Get Anthropic style function descriptions.
|
||||
|
||||
.. deprecated:: 4.0.0
|
||||
Use anthropic_schema() instead.
|
||||
"""
|
||||
return self.anthropic_schema()
|
||||
|
||||
def get_func_desc_google_genai_style(self):
|
||||
"""Get Google GenAI style function descriptions.
|
||||
|
||||
.. deprecated:: 4.0.0
|
||||
Use google_schema() instead.
|
||||
"""
|
||||
return self.google_schema()
|
||||
|
||||
def names(self) -> list[str]:
|
||||
"""Get a list of all tool names."""
|
||||
return [tool.name for tool in self.tools]
|
||||
|
||||
def merge(self, other: ToolSet) -> None:
|
||||
"""Merge another ToolSet into this one."""
|
||||
for tool in other.tools:
|
||||
self.add_tool(tool)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.tools)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return len(self.tools) > 0
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.tools)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ToolSet(tools={self.tools})"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"ToolSet(tools={self.tools})"
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Built-in tools for AstrBot.
|
||||
|
||||
This module provides access to all AstrBot built-in tools that are part of
|
||||
the internal tool providers system.
|
||||
"""
|
||||
|
||||
from astrbot._internal.tools.base import FunctionTool
|
||||
from astrbot._internal.tools.builtin.cron import (
|
||||
CREATE_CRON_JOB_TOOL,
|
||||
DELETE_CRON_JOB_TOOL,
|
||||
LIST_CRON_JOBS_TOOL,
|
||||
)
|
||||
from astrbot._internal.tools.builtin.cron import (
|
||||
get_all_tools as cron_get_all_tools,
|
||||
)
|
||||
from astrbot._internal.tools.builtin.kb_query import (
|
||||
KNOWLEDGE_BASE_QUERY_TOOL,
|
||||
)
|
||||
from astrbot._internal.tools.builtin.kb_query import (
|
||||
get_all_tools as kb_query_get_all_tools,
|
||||
)
|
||||
from astrbot._internal.tools.builtin.send_message import (
|
||||
SEND_MESSAGE_TO_USER_TOOL,
|
||||
)
|
||||
from astrbot._internal.tools.builtin.send_message import (
|
||||
get_all_tools as send_message_get_all_tools,
|
||||
)
|
||||
|
||||
|
||||
def get_all_tools() -> list[FunctionTool]:
|
||||
"""Return all built-in tools for registration.
|
||||
|
||||
This aggregates tools from all built-in tool modules:
|
||||
- cron tools (create/delete/list future tasks)
|
||||
- knowledge base query tool
|
||||
- send message tool
|
||||
"""
|
||||
tools: list[FunctionTool] = []
|
||||
tools.extend(cron_get_all_tools())
|
||||
tools.extend(kb_query_get_all_tools())
|
||||
tools.extend(send_message_get_all_tools())
|
||||
return tools
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CREATE_CRON_JOB_TOOL",
|
||||
"DELETE_CRON_JOB_TOOL",
|
||||
"KNOWLEDGE_BASE_QUERY_TOOL",
|
||||
"LIST_CRON_JOBS_TOOL",
|
||||
"SEND_MESSAGE_TO_USER_TOOL",
|
||||
"get_all_tools",
|
||||
]
|
||||
@@ -1,201 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.dataclasses import dataclass
|
||||
|
||||
from astrbot._internal.tools.base import FunctionTool, ToolExecResult
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
|
||||
def _extract_job_session(job: Any) -> str | None:
|
||||
payload = getattr(job, "payload", None)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
session = payload.get("session")
|
||||
return str(session) if session is not None else None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "create_future_task"
|
||||
description: str = (
|
||||
"Create a future task for your future. Supports recurring cron expressions or one-time run_at datetime. "
|
||||
"Use this when you or the user want scheduled follow-up or proactive actions."
|
||||
)
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cron_expression": {
|
||||
"type": "string",
|
||||
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *' or '0 23 * * mon-fri'). Prefer named weekdays like 'mon-fri' or 'sat,sun' instead of numeric day-of-week ranges such as '1-5' to avoid ambiguity across cron implementations.",
|
||||
},
|
||||
"run_at": {
|
||||
"type": "string",
|
||||
"description": "ISO datetime for one-time execution, e.g., 2026-02-02T08:00:00+08:00. Use with run_once=true.",
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"description": "Detailed instructions for your future agent to execute when it wakes.",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional label to recognize this future task.",
|
||||
},
|
||||
"run_once": {
|
||||
"type": "boolean",
|
||||
"description": "If true, the task will run only once and then be deleted. Use run_at to specify the time.",
|
||||
},
|
||||
},
|
||||
"required": ["note"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
||||
) -> ToolExecResult:
|
||||
cron_mgr = context.context.context.cron_manager
|
||||
if cron_mgr is None:
|
||||
return "error: cron manager is not available."
|
||||
|
||||
cron_expression = kwargs.get("cron_expression")
|
||||
run_at = kwargs.get("run_at")
|
||||
run_once = bool(kwargs.get("run_once", False))
|
||||
note = str(kwargs.get("note", "")).strip()
|
||||
name = str(kwargs.get("name") or "").strip() or "active_agent_task"
|
||||
|
||||
if not note:
|
||||
return "error: note is required."
|
||||
if run_once and not run_at:
|
||||
return "error: run_at is required when run_once=true."
|
||||
if (not run_once) and not cron_expression:
|
||||
return "error: cron_expression is required when run_once=false."
|
||||
if run_once and cron_expression:
|
||||
cron_expression = None
|
||||
run_at_dt = None
|
||||
if run_at:
|
||||
try:
|
||||
run_at_dt = datetime.fromisoformat(str(run_at))
|
||||
except Exception:
|
||||
return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00"
|
||||
|
||||
payload = {
|
||||
"session": context.context.event.unified_msg_origin,
|
||||
"sender_id": context.context.event.get_sender_id(),
|
||||
"note": note,
|
||||
"origin": "tool",
|
||||
}
|
||||
|
||||
job = await cron_mgr.add_active_job(
|
||||
name=name,
|
||||
cron_expression=str(cron_expression) if cron_expression else None,
|
||||
payload=payload,
|
||||
description=note,
|
||||
run_once=run_once,
|
||||
run_at=run_at_dt,
|
||||
)
|
||||
next_run = job.next_run_time or run_at_dt
|
||||
suffix = (
|
||||
f"one-time at {next_run}"
|
||||
if run_once
|
||||
else f"expression '{cron_expression}' (next {next_run})"
|
||||
)
|
||||
return f"Scheduled future task {job.job_id} ({job.name}) {suffix}."
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeleteCronJobTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "delete_future_task"
|
||||
description: str = "Delete a future task (cron job) by its job_id."
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "The job_id returned when the job was created.",
|
||||
}
|
||||
},
|
||||
"required": ["job_id"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
||||
) -> ToolExecResult:
|
||||
cron_mgr = context.context.context.cron_manager
|
||||
if cron_mgr is None:
|
||||
return "error: cron manager is not available."
|
||||
current_umo = context.context.event.unified_msg_origin
|
||||
job_id = kwargs.get("job_id")
|
||||
if not job_id:
|
||||
return "error: job_id is required."
|
||||
job = await cron_mgr.db.get_cron_job(str(job_id))
|
||||
if not job:
|
||||
return f"error: cron job {job_id} not found."
|
||||
if _extract_job_session(job) != current_umo:
|
||||
return "error: you can only delete future tasks in the current umo."
|
||||
await cron_mgr.delete_job(str(job_id))
|
||||
return f"Deleted cron job {job_id}."
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListCronJobsTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "list_future_tasks"
|
||||
description: str = "List existing future tasks (cron jobs) for inspection."
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_type": {
|
||||
"type": "string",
|
||||
"description": "Optional filter: basic or active_agent.",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
||||
) -> ToolExecResult:
|
||||
cron_mgr = context.context.context.cron_manager
|
||||
if cron_mgr is None:
|
||||
return "error: cron manager is not available."
|
||||
current_umo = context.context.event.unified_msg_origin
|
||||
job_type = kwargs.get("job_type")
|
||||
jobs = [
|
||||
job
|
||||
for job in await cron_mgr.list_jobs(job_type)
|
||||
if _extract_job_session(job) == current_umo
|
||||
]
|
||||
if not jobs:
|
||||
return "No cron jobs found."
|
||||
lines = []
|
||||
for j in jobs:
|
||||
lines.append(
|
||||
f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
CREATE_CRON_JOB_TOOL = CreateActiveCronTool()
|
||||
DELETE_CRON_JOB_TOOL = DeleteCronJobTool()
|
||||
LIST_CRON_JOBS_TOOL = ListCronJobsTool()
|
||||
|
||||
|
||||
def get_all_tools() -> list[FunctionTool]:
|
||||
"""Return all cron-related tools for registration."""
|
||||
return [CREATE_CRON_JOB_TOOL, DELETE_CRON_JOB_TOOL, LIST_CRON_JOBS_TOOL]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CREATE_CRON_JOB_TOOL",
|
||||
"DELETE_CRON_JOB_TOOL",
|
||||
"LIST_CRON_JOBS_TOOL",
|
||||
"CreateActiveCronTool",
|
||||
"DeleteCronJobTool",
|
||||
"ListCronJobsTool",
|
||||
"get_all_tools",
|
||||
]
|
||||
@@ -1,139 +0,0 @@
|
||||
"""Knowledge base query tool and retrieval logic.
|
||||
|
||||
Extracted from ``astr_main_agent_resources.py`` to its own module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.dataclasses import dataclass
|
||||
|
||||
from astrbot._internal.tools.base import FunctionTool, ToolExecResult
|
||||
from astrbot.api import logger, sp
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.star.context import Context
|
||||
|
||||
|
||||
@dataclass
|
||||
class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "astr_kb_search"
|
||||
description: str = (
|
||||
"Query the knowledge base for facts or relevant context. "
|
||||
"Use this tool when the user's question requires factual information, "
|
||||
"definitions, background knowledge, or previously indexed content. "
|
||||
"Only send short keywords or a concise question as the query."
|
||||
)
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "A concise keyword query for the knowledge base.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
||||
) -> ToolExecResult:
|
||||
query = kwargs.get("query", "")
|
||||
if not query:
|
||||
return "error: Query parameter is empty."
|
||||
result = await retrieve_knowledge_base(
|
||||
query=kwargs.get("query", ""),
|
||||
umo=context.context.event.unified_msg_origin,
|
||||
context=context.context.context,
|
||||
)
|
||||
if not result:
|
||||
return "No relevant knowledge found."
|
||||
return result
|
||||
|
||||
|
||||
async def retrieve_knowledge_base(
|
||||
query: str,
|
||||
umo: str,
|
||||
context: Context,
|
||||
) -> str | None:
|
||||
"""Inject knowledge base context into the provider request
|
||||
|
||||
Args:
|
||||
query: The search query string
|
||||
umo: Unique message object (session ID)
|
||||
context: Star context
|
||||
"""
|
||||
kb_mgr = context.kb_manager
|
||||
config = context.get_config(umo=umo)
|
||||
|
||||
# 1. Prefer session-level config
|
||||
session_config = await sp.session_get(umo, "kb_config", default={})
|
||||
|
||||
if session_config and "kb_ids" in session_config:
|
||||
kb_ids = session_config.get("kb_ids", [])
|
||||
|
||||
if not kb_ids:
|
||||
logger.info(f"[知识库] 会话 {umo} 已被配置为不使用知识库")
|
||||
return
|
||||
|
||||
top_k = session_config.get("top_k", 5)
|
||||
|
||||
kb_names = []
|
||||
invalid_kb_ids = []
|
||||
for kb_id in kb_ids:
|
||||
kb_helper = await kb_mgr.get_kb(kb_id)
|
||||
if kb_helper:
|
||||
kb_names.append(kb_helper.kb.kb_name)
|
||||
else:
|
||||
logger.warning(f"[知识库] 知识库不存在或未加载: {kb_id}")
|
||||
invalid_kb_ids.append(kb_id)
|
||||
|
||||
if invalid_kb_ids:
|
||||
logger.warning(
|
||||
f"[知识库] 会话 {umo} 配置的以下知识库无效: {invalid_kb_ids}",
|
||||
)
|
||||
|
||||
if not kb_names:
|
||||
return
|
||||
|
||||
logger.debug(f"[知识库] 使用会话级配置,知识库数量: {len(kb_names)}")
|
||||
else:
|
||||
kb_names = config.get("kb_names", [])
|
||||
top_k = config.get("kb_final_top_k", 5)
|
||||
logger.debug(f"[知识库] 使用全局配置,知识库数量: {len(kb_names)}")
|
||||
|
||||
top_k_fusion = config.get("kb_fusion_top_k", 20)
|
||||
|
||||
if not kb_names:
|
||||
return
|
||||
|
||||
logger.debug(f"[知识库] 开始检索知识库,数量: {len(kb_names)}, top_k={top_k}")
|
||||
kb_context = await kb_mgr.retrieve(
|
||||
query=query,
|
||||
kb_names=kb_names,
|
||||
top_k_fusion=top_k_fusion,
|
||||
top_m_final=top_k,
|
||||
)
|
||||
|
||||
if not kb_context:
|
||||
return
|
||||
|
||||
formatted = kb_context.get("context_text", "")
|
||||
if formatted:
|
||||
results = kb_context.get("results", [])
|
||||
logger.debug(f"[知识库] 为会话 {umo} 注入了 {len(results)} 条相关知识块")
|
||||
return formatted
|
||||
|
||||
|
||||
KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()
|
||||
|
||||
|
||||
def get_all_tools() -> list[FunctionTool]:
|
||||
"""Return all knowledge-base tools for registration."""
|
||||
return [KNOWLEDGE_BASE_QUERY_TOOL]
|
||||
@@ -1,226 +0,0 @@
|
||||
"""SendMessageToUserTool — proactive message delivery to users.
|
||||
|
||||
Extracted from ``astr_main_agent_resources.py`` to its own module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
import anyio
|
||||
from pydantic import Field
|
||||
from pydantic.dataclasses import dataclass
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot._internal.tools.base import FunctionTool, ToolExecResult
|
||||
from astrbot.api import logger
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.computer.computer_client import get_booter
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.platform.message_session import MessageSession
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
|
||||
class MessageComponent(TypedDict, total=False):
|
||||
"""Type-safe message component structure."""
|
||||
|
||||
type: str
|
||||
text: str
|
||||
path: str
|
||||
url: str
|
||||
mention_user_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "send_message_to_user"
|
||||
description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation."
|
||||
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"messages": {
|
||||
"type": "array",
|
||||
"description": "An ordered list of message components to send. `mention_user` type can be used to mention the user.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["messages"],
|
||||
}
|
||||
)
|
||||
|
||||
async def _resolve_path_from_sandbox(
|
||||
self, context: ContextWrapper[AstrAgentContext], path: str
|
||||
) -> tuple[str, bool]:
|
||||
"""
|
||||
If the path exists locally, return it directly.
|
||||
Otherwise, check if it exists in the sandbox and download it.
|
||||
|
||||
bool: indicates whether the file was downloaded from sandbox.
|
||||
"""
|
||||
if await anyio.Path(path).exists():
|
||||
return path, False
|
||||
|
||||
# Try to check if the file exists in the sandbox
|
||||
try:
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
# Use shell to check if the file exists in sandbox
|
||||
import shlex
|
||||
|
||||
result = await sb.shell.exec(
|
||||
f"test -f {shlex.quote(path)} && echo '_&exists_'"
|
||||
)
|
||||
if "_&exists_" in json.dumps(result):
|
||||
# Download the file from sandbox
|
||||
name = anyio.Path(path).name
|
||||
local_path = os.path.join(
|
||||
get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}"
|
||||
)
|
||||
await sb.download_file(path, local_path)
|
||||
logger.info(f"Downloaded file from sandbox: {path} -> {local_path}")
|
||||
return local_path, True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check/download file from sandbox: {e}")
|
||||
|
||||
# Return the original path (will likely fail later, but that's expected)
|
||||
return path, False
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], **kwargs: Any
|
||||
) -> ToolExecResult:
|
||||
session: str | MessageSession = (
|
||||
kwargs.get("session") or context.context.event.unified_msg_origin
|
||||
)
|
||||
messages: list[dict[str, Any]] | None = kwargs.get("messages")
|
||||
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return "error: messages parameter is empty or invalid."
|
||||
|
||||
components: list[Comp.BaseMessageComponent] = []
|
||||
|
||||
for idx, msg in enumerate(messages):
|
||||
if not isinstance(msg, dict):
|
||||
return f"error: messages[{idx}] should be an object."
|
||||
|
||||
msg_dict: dict[str, Any] = cast(dict[str, Any], msg)
|
||||
|
||||
if "type" not in msg_dict:
|
||||
return f"error: messages[{idx}].type is required."
|
||||
msg_type = str(msg_dict["type"]).lower()
|
||||
|
||||
_file_from_sandbox = False
|
||||
|
||||
try:
|
||||
if msg_type == "plain":
|
||||
text = str(msg_dict.get("text", "")).strip()
|
||||
if not text:
|
||||
return f"error: messages[{idx}].text is required for plain component."
|
||||
components.append(Comp.Plain(text=text))
|
||||
elif msg_type == "image":
|
||||
path = msg_dict.get("path")
|
||||
url = msg_dict.get("url")
|
||||
if path:
|
||||
(
|
||||
local_path,
|
||||
_file_from_sandbox,
|
||||
) = await self._resolve_path_from_sandbox(context, path)
|
||||
components.append(Comp.Image.fromFileSystem(path=local_path))
|
||||
elif url:
|
||||
components.append(Comp.Image.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for image component."
|
||||
elif msg_type == "record":
|
||||
path = msg_dict.get("path")
|
||||
url = msg_dict.get("url")
|
||||
if path:
|
||||
(
|
||||
local_path,
|
||||
_file_from_sandbox,
|
||||
) = await self._resolve_path_from_sandbox(context, path)
|
||||
components.append(Comp.Record.fromFileSystem(path=local_path))
|
||||
elif url:
|
||||
components.append(Comp.Record.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for record component."
|
||||
elif msg_type == "video":
|
||||
path = msg_dict.get("path")
|
||||
url = msg_dict.get("url")
|
||||
if path:
|
||||
(
|
||||
local_path,
|
||||
_file_from_sandbox,
|
||||
) = await self._resolve_path_from_sandbox(context, path)
|
||||
components.append(Comp.Video.fromFileSystem(path=local_path))
|
||||
elif url:
|
||||
components.append(Comp.Video.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for video component."
|
||||
elif msg_type == "file":
|
||||
path = msg_dict.get("path")
|
||||
url = msg_dict.get("url")
|
||||
name = (
|
||||
msg_dict.get("text")
|
||||
or (os.path.basename(path) if path else "")
|
||||
or (os.path.basename(url) if url else "")
|
||||
or "file"
|
||||
)
|
||||
if path:
|
||||
(
|
||||
local_path,
|
||||
_file_from_sandbox,
|
||||
) = await self._resolve_path_from_sandbox(context, path)
|
||||
components.append(Comp.File(name=name, file=local_path))
|
||||
elif url:
|
||||
components.append(Comp.File(name=name, url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for file component."
|
||||
elif msg_type == "mention_user":
|
||||
mention_user_id = msg_dict.get("mention_user_id")
|
||||
if not mention_user_id:
|
||||
return f"error: messages[{idx}].mention_user_id is required for mention_user component."
|
||||
components.append(
|
||||
Comp.At(
|
||||
qq=mention_user_id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
return (
|
||||
f"error: unsupported message type '{msg_type}' at index {idx}."
|
||||
)
|
||||
except Exception as exc:
|
||||
return f"error: failed to build messages[{idx}] component: {exc}"
|
||||
|
||||
try:
|
||||
target_session = (
|
||||
MessageSession.from_str(session)
|
||||
if isinstance(session, str)
|
||||
else session
|
||||
)
|
||||
except Exception as e:
|
||||
return f"error: invalid session: {e}"
|
||||
|
||||
await context.context.context.send_message(
|
||||
target_session,
|
||||
MessageChain(chain=components),
|
||||
)
|
||||
|
||||
return f"Message sent to session {target_session}"
|
||||
|
||||
|
||||
SEND_MESSAGE_TO_USER_TOOL = SendMessageToUserTool()
|
||||
|
||||
|
||||
def get_all_tools() -> list[FunctionTool]:
|
||||
"""Return all send-message tools for registration."""
|
||||
return [SEND_MESSAGE_TO_USER_TOOL]
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Tool providers for AstrBot.
|
||||
|
||||
This module provides different tool providers that supply tools
|
||||
through a unified interface:
|
||||
|
||||
- InternalToolProvider: Provides built-in AstrBot tools (cron, kb_query, send_message)
|
||||
- PluginToolProvider: Provides tools registered by star plugins
|
||||
- ComputerToolProvider: Provides computer-use tools (shell, Python, file ops, etc.)
|
||||
"""
|
||||
|
||||
from astrbot._internal.tools.providers.computer import (
|
||||
ComputerToolProvider,
|
||||
)
|
||||
from astrbot._internal.tools.providers.internal import (
|
||||
InternalToolProvider,
|
||||
)
|
||||
from astrbot._internal.tools.providers.plugin import (
|
||||
PluginToolProvider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ComputerToolProvider",
|
||||
"InternalToolProvider",
|
||||
"PluginToolProvider",
|
||||
]
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Computer tools provider for AstrBot.
|
||||
|
||||
This provider wraps the ComputerToolProvider from the computer module
|
||||
to ensure computer tools are available through the unified provider interface.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot._internal.tools.base import FunctionTool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class ComputerToolProvider:
|
||||
"""Provider for computer-use tools (local/sandbox).
|
||||
|
||||
This class wraps the existing ``ComputerToolProvider`` from
|
||||
``astrbot.core.computer.computer_tool_provider`` to integrate
|
||||
computer tools into the unified provider interface.
|
||||
|
||||
The computer tools include shell execution, Python code execution,
|
||||
file operations, browser automation, and skill management tools.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_all_tools() -> list[FunctionTool]:
|
||||
"""Return all computer-use tools across all runtimes.
|
||||
|
||||
Delegates to ``ComputerToolProvider.get_all_tools()`` which
|
||||
collects tools from all runtimes (local, sandbox, browser, neo).
|
||||
|
||||
Creates **fresh instances** separate from the runtime caches so
|
||||
that setting ``active=False`` on them does not affect runtime
|
||||
behaviour. These registration-only instances let the WebUI display
|
||||
and assign tools without injecting them into actual LLM requests.
|
||||
|
||||
Returns:
|
||||
list[FunctionTool]: A list of all computer FunctionTool instances.
|
||||
"""
|
||||
from astrbot.core.computer.computer_tool_provider import (
|
||||
ComputerToolProvider as CoreComputerToolProvider,
|
||||
)
|
||||
|
||||
return CoreComputerToolProvider.get_all_tools()
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Internal tools provider for AstrBot.
|
||||
|
||||
This provider wraps the logic for loading built-in internal tools from
|
||||
the provider modules: cron_tools, kb_query, and send_message.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot._internal.tools.base import FunctionTool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
# Provider modules that supply internal tools
|
||||
_INTERNAL_PROVIDER_MODULES: list[str] = [
|
||||
"astrbot.core.tools.cron_tools",
|
||||
"astrbot.core.tools.kb_query",
|
||||
"astrbot.core.tools.send_message",
|
||||
]
|
||||
|
||||
|
||||
class InternalToolProvider:
|
||||
"""Provider for AstrBot built-in internal tools.
|
||||
|
||||
This class wraps the logic previously found in
|
||||
``FunctionToolManager._INTERNAL_TOOL_PROVIDERS`` and provides
|
||||
a unified interface for loading tools from the internal provider
|
||||
modules.
|
||||
|
||||
Each provider module is expected to expose a ``get_all_tools()``
|
||||
function that returns a list of ``FunctionTool`` instances.
|
||||
|
||||
Tools are marked with ``source='internal'`` so the WebUI can
|
||||
distinguish them from plugin and MCP tools.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_all_tools() -> list[FunctionTool]:
|
||||
"""Return all internal tools from all provider modules.
|
||||
|
||||
Iterates through the provider modules and collects tools
|
||||
from each module's ``get_all_tools()`` function.
|
||||
|
||||
Returns:
|
||||
list[FunctionTool]: A list of all internal FunctionTool instances.
|
||||
"""
|
||||
all_tools: list[FunctionTool] = []
|
||||
existing_names: set[str] = set()
|
||||
|
||||
for module_path in _INTERNAL_PROVIDER_MODULES:
|
||||
try:
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module(module_path)
|
||||
provider_tools = mod.get_all_tools()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to load internal tool provider %s: %s",
|
||||
module_path,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
|
||||
for tool in provider_tools:
|
||||
tool.source = "internal"
|
||||
if tool.name not in existing_names:
|
||||
all_tools.append(tool)
|
||||
existing_names.add(tool.name)
|
||||
logger.debug("Loaded internal tool: %s", tool.name)
|
||||
|
||||
return all_tools
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Plugin tools provider for AstrBot.
|
||||
|
||||
This provider handles loading tools from star plugins. Plugin tools
|
||||
are discovered through the star plugin system and made available
|
||||
through the unified provider interface.
|
||||
"""
|
||||
|
||||
from astrbot._internal.tools.base import FunctionTool
|
||||
|
||||
|
||||
class PluginToolProvider:
|
||||
"""Provider for tools from star plugins.
|
||||
|
||||
This class handles loading tools that are registered by star plugins.
|
||||
Plugin tools are discovered through the plugin system and integrated
|
||||
into the tool registry.
|
||||
|
||||
Note: Plugin tools are typically registered dynamically through the
|
||||
plugin context (``Context.register_llm_tool()``) and are managed
|
||||
by the ``FunctionToolManager`` in the provider module.
|
||||
|
||||
This provider class serves as an integration point for the plugin
|
||||
tool system with the unified internal tools architecture.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_all_tools() -> list[FunctionTool]:
|
||||
"""Return all plugin-registered tools.
|
||||
|
||||
This method retrieves tools that have been registered by plugins
|
||||
through the ``FunctionToolManager``. It accesses the global
|
||||
``llm_tools`` instance from ``astrbot.core.provider.register``.
|
||||
|
||||
Returns:
|
||||
list[FunctionTool]: A list of all plugin FunctionTool instances.
|
||||
"""
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
from astrbot.core.star.star import star_map
|
||||
|
||||
# Get all tools from the FunctionToolManager that are from plugins
|
||||
plugin_tools: list[FunctionTool] = []
|
||||
existing_names: set[str] = set()
|
||||
|
||||
for tool in llm_tools.func_list:
|
||||
# Only include tools that are marked as 'plugin' source
|
||||
# and belong to an activated plugin
|
||||
if tool.source == "plugin":
|
||||
if tool.name not in existing_names:
|
||||
if tool.handler_module_path:
|
||||
star_meta = star_map.get(tool.handler_module_path)
|
||||
if star_meta and star_meta.activated:
|
||||
plugin_tools.append(tool)
|
||||
existing_names.add(tool.name)
|
||||
else:
|
||||
# Tools without handler_module_path are treated as plugin tools
|
||||
plugin_tools.append(tool)
|
||||
existing_names.add(tool.name)
|
||||
|
||||
return plugin_tools
|
||||
|
||||
@staticmethod
|
||||
def get_tool(name: str) -> FunctionTool | None:
|
||||
"""Get a specific plugin tool by name.
|
||||
|
||||
Args:
|
||||
name: The name of the tool to retrieve.
|
||||
|
||||
Returns:
|
||||
FunctionTool | None: The tool if found, None otherwise.
|
||||
"""
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
|
||||
return llm_tools.get_func(name)
|
||||
@@ -1,821 +0,0 @@
|
||||
"""FunctionTool registry and manager for AstrBot.
|
||||
|
||||
This module provides the FunctionToolManager class that serves as the central
|
||||
registry for all function tools (built-in, plugin, and MCP).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import urllib.parse
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import aiofiles
|
||||
import anyio
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core import sp
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
from .base import FunctionTool, ToolSet
|
||||
|
||||
# Deferred imports to avoid circular dependency
|
||||
_mcp_client_module = None
|
||||
_mcp_tool_module = None
|
||||
|
||||
|
||||
def _get_mcp_client():
|
||||
global _mcp_client_module
|
||||
if _mcp_client_module is None:
|
||||
from astrbot._internal.mcp import client as module
|
||||
_mcp_client_module = module
|
||||
return _mcp_client_module
|
||||
|
||||
|
||||
def _get_mcp_tool():
|
||||
global _mcp_tool_module
|
||||
if _mcp_tool_module is None:
|
||||
from astrbot._internal.mcp import tool as module
|
||||
_mcp_tool_module = module
|
||||
return _mcp_tool_module
|
||||
|
||||
|
||||
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
|
||||
|
||||
DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 180.0
|
||||
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 180.0
|
||||
MCP_INIT_TIMEOUT_ENV = "ASTRBOT_MCP_INIT_TIMEOUT"
|
||||
ENABLE_MCP_TIMEOUT_ENV = "ASTRBOT_MCP_ENABLE_TIMEOUT"
|
||||
MAX_MCP_TIMEOUT_SECONDS = 300.0
|
||||
|
||||
|
||||
class MCPInitError(Exception):
|
||||
"""Base exception for MCP initialization failures."""
|
||||
|
||||
|
||||
class MCPInitTimeoutError(asyncio.TimeoutError, MCPInitError):
|
||||
"""Raised when MCP client initialization exceeds the configured timeout."""
|
||||
|
||||
|
||||
class MCPAllServicesFailedError(MCPInitError):
|
||||
"""Raised when all configured MCP services fail to initialize."""
|
||||
|
||||
|
||||
class MCPShutdownTimeoutError(asyncio.TimeoutError):
|
||||
"""Raised when MCP shutdown exceeds the configured timeout."""
|
||||
|
||||
def __init__(self, names: list[str], timeout: float) -> None:
|
||||
self.names = names
|
||||
self.timeout = timeout
|
||||
message = f"MCP 服务关闭超时({timeout:g} 秒):{', '.join(names)}"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPInitSummary:
|
||||
total: int
|
||||
success: int
|
||||
failed: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MCPServerRuntime:
|
||||
name: str
|
||||
client: Any # MCPClient
|
||||
shutdown_event: asyncio.Event
|
||||
lifecycle_task: asyncio.Task[None]
|
||||
|
||||
|
||||
class _MCPClientDictView(Mapping[str, Any]):
|
||||
"""Read-only view of MCP clients derived from runtime state."""
|
||||
|
||||
def __init__(self, runtime: dict[str, _MCPServerRuntime]) -> None:
|
||||
self._runtime = runtime
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self._runtime[key].client
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._runtime)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._runtime)
|
||||
|
||||
|
||||
def _resolve_timeout(
|
||||
timeout: float | str | None = None,
|
||||
*,
|
||||
env_name: str = MCP_INIT_TIMEOUT_ENV,
|
||||
default: float = DEFAULT_MCP_INIT_TIMEOUT_SECONDS,
|
||||
) -> float:
|
||||
"""Resolve timeout with precedence: explicit argument > env value > default."""
|
||||
source = f"环境变量 {env_name}"
|
||||
if timeout is None:
|
||||
timeout = os.getenv(env_name, str(default))
|
||||
else:
|
||||
source = "显式参数 timeout"
|
||||
|
||||
try:
|
||||
timeout_value = float(timeout)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
f"超时配置({source})={timeout!r} 无效,使用默认值 {default:g} 秒。"
|
||||
)
|
||||
return default
|
||||
|
||||
if timeout_value <= 0:
|
||||
logger.warning(
|
||||
f"超时配置({source})={timeout_value:g} 必须大于 0,使用默认值 {default:g} 秒。"
|
||||
)
|
||||
return default
|
||||
|
||||
if timeout_value > MAX_MCP_TIMEOUT_SECONDS:
|
||||
logger.warning(
|
||||
f"超时配置({source})={timeout_value:g} 过大,已限制为最大值 "
|
||||
f"{MAX_MCP_TIMEOUT_SECONDS:g} 秒,以避免长时间等待。"
|
||||
)
|
||||
return MAX_MCP_TIMEOUT_SECONDS
|
||||
|
||||
return timeout_value
|
||||
|
||||
|
||||
SUPPORTED_TYPES = [
|
||||
"string",
|
||||
"number",
|
||||
"object",
|
||||
"array",
|
||||
"boolean",
|
||||
]
|
||||
|
||||
PY_TO_JSON_TYPE = {
|
||||
"int": "number",
|
||||
"float": "number",
|
||||
"bool": "boolean",
|
||||
"str": "string",
|
||||
"dict": "object",
|
||||
"list": "array",
|
||||
"tuple": "array",
|
||||
"set": "array",
|
||||
}
|
||||
|
||||
|
||||
class FunctionToolManager:
|
||||
"""Central registry for all function tools in AstrBot.
|
||||
|
||||
This class manages:
|
||||
- Built-in tools (cron, KB query, send message, computer)
|
||||
- Plugin tools
|
||||
- MCP tools (from external MCP servers)
|
||||
|
||||
Tools are stored in func_list and can be queried by name.
|
||||
MCP servers are tracked separately in _mcp_server_runtime.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.func_list: list[FunctionTool] = []
|
||||
self._mcp_server_runtime: dict[str, _MCPServerRuntime] = {}
|
||||
self._mcp_server_runtime_view = MappingProxyType(self._mcp_server_runtime)
|
||||
self._mcp_client_dict_view = _MCPClientDictView(self._mcp_server_runtime)
|
||||
self._timeout_mismatch_warned = False
|
||||
self._timeout_warn_lock = threading.Lock()
|
||||
self._runtime_lock = asyncio.Lock()
|
||||
self._mcp_starting: set[str] = set()
|
||||
self._init_timeout_default = _resolve_timeout(
|
||||
timeout=None,
|
||||
env_name=MCP_INIT_TIMEOUT_ENV,
|
||||
default=DEFAULT_MCP_INIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
self._enable_timeout_default = _resolve_timeout(
|
||||
timeout=None,
|
||||
env_name=ENABLE_MCP_TIMEOUT_ENV,
|
||||
default=DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS,
|
||||
)
|
||||
self._warn_on_timeout_mismatch(
|
||||
self._init_timeout_default,
|
||||
self._enable_timeout_default,
|
||||
)
|
||||
|
||||
@property
|
||||
def mcp_client_dict(self) -> Mapping[str, Any]:
|
||||
"""Read-only view of MCP clients."""
|
||||
return self._mcp_client_dict_view
|
||||
|
||||
@property
|
||||
def mcp_server_runtime_view(self) -> Mapping[str, _MCPServerRuntime]:
|
||||
"""Read-only view of MCP runtime metadata."""
|
||||
return self._mcp_server_runtime_view
|
||||
|
||||
@property
|
||||
def mcp_server_runtime(self) -> Mapping[str, _MCPServerRuntime]:
|
||||
"""Backward-compatible read-only view (deprecated)."""
|
||||
return self._mcp_server_runtime_view
|
||||
|
||||
def empty(self) -> bool:
|
||||
return len(self.func_list) == 0
|
||||
|
||||
def spec_to_func(
|
||||
self,
|
||||
name: str,
|
||||
func_args: list[dict],
|
||||
desc: str,
|
||||
handler: Callable[..., Awaitable[Any] | AsyncGenerator[Any]],
|
||||
) -> FunctionTool:
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}
|
||||
for param in func_args:
|
||||
p = param.copy()
|
||||
p.pop("name", None)
|
||||
params["properties"][param["name"]] = p
|
||||
return FunctionTool(
|
||||
name=name,
|
||||
parameters=params,
|
||||
description=desc,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
def add_func(
|
||||
self,
|
||||
name: str,
|
||||
func_args: list,
|
||||
desc: str,
|
||||
handler: Callable[..., Awaitable[Any] | AsyncGenerator[Any]],
|
||||
) -> None:
|
||||
"""Add a function tool."""
|
||||
self.remove_func(name)
|
||||
self.func_list.append(
|
||||
self.spec_to_func(
|
||||
name=name,
|
||||
func_args=func_args,
|
||||
desc=desc,
|
||||
handler=handler,
|
||||
),
|
||||
)
|
||||
logger.info(f"添加函数调用工具: {name}")
|
||||
|
||||
def remove_func(self, name: str) -> None:
|
||||
"""Remove a function tool by name."""
|
||||
for i, f in enumerate(self.func_list):
|
||||
if f.name == name:
|
||||
self.func_list.pop(i)
|
||||
break
|
||||
|
||||
def get_func(self, name: str) -> FunctionTool | None:
|
||||
"""Get a function tool by name.
|
||||
|
||||
Prefers active tools, falls back to last registered.
|
||||
"""
|
||||
for f in reversed(self.func_list):
|
||||
if f.name == name and getattr(f, "active", True):
|
||||
return f
|
||||
for f in reversed(self.func_list):
|
||||
if f.name == name:
|
||||
return f
|
||||
return None
|
||||
|
||||
def get_full_tool_set(self) -> ToolSet:
|
||||
"""Get all tools as a ToolSet."""
|
||||
tool_set = ToolSet()
|
||||
for tool in self.func_list:
|
||||
tool_set.add_tool(tool)
|
||||
return tool_set
|
||||
|
||||
@staticmethod
|
||||
def _log_safe_mcp_debug_config(cfg: dict) -> None:
|
||||
"""Log sanitized MCP config for debugging."""
|
||||
if "command" in cfg:
|
||||
cmd = cfg["command"]
|
||||
executable = str(cmd[0] if isinstance(cmd, (list, tuple)) and cmd else cmd)
|
||||
args_val = cfg.get("args", [])
|
||||
args_count = (
|
||||
len(args_val)
|
||||
if isinstance(args_val, (list, tuple))
|
||||
else (0 if args_val is None else 1)
|
||||
)
|
||||
logger.debug(f" 命令可执行文件: {executable}, 参数数量: {args_count}")
|
||||
return
|
||||
|
||||
if "url" in cfg:
|
||||
parsed = urllib.parse.urlparse(str(cfg["url"]))
|
||||
host = parsed.hostname or ""
|
||||
scheme = parsed.scheme or "unknown"
|
||||
try:
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
except ValueError:
|
||||
port = ""
|
||||
logger.debug(f" 主机: {scheme}://{host}{port}")
|
||||
|
||||
async def init_mcp_clients(
|
||||
self, raise_on_all_failed: bool = False
|
||||
) -> MCPInitSummary:
|
||||
"""Initialize MCP clients from mcp_server.json config."""
|
||||
data_dir = get_astrbot_data_path()
|
||||
|
||||
mcp_json_file = os.path.join(data_dir, "mcp_server.json")
|
||||
mcp_json_path = anyio.Path(mcp_json_file)
|
||||
if not await mcp_json_path.exists():
|
||||
async with aiofiles.open(mcp_json_file, "w", encoding="utf-8") as f:
|
||||
await f.write(
|
||||
json.dumps(DEFAULT_MCP_CONFIG, ensure_ascii=False, indent=4)
|
||||
)
|
||||
logger.info(f"未找到 MCP 服务配置文件,已创建默认配置文件 {mcp_json_file}")
|
||||
return MCPInitSummary(total=0, success=0, failed=[])
|
||||
|
||||
async with aiofiles.open(mcp_json_file, encoding="utf-8") as f:
|
||||
mcp_server_json_obj: dict[str, dict] = json.loads(await f.read())[
|
||||
"mcpServers"
|
||||
]
|
||||
|
||||
init_timeout = self._init_timeout_default
|
||||
timeout_display = f"{init_timeout:g}"
|
||||
|
||||
active_configs: list[tuple[str, dict, asyncio.Event]] = []
|
||||
for name, cfg in mcp_server_json_obj.items():
|
||||
if cfg.get("active", True):
|
||||
shutdown_event = asyncio.Event()
|
||||
active_configs.append((name, cfg, shutdown_event))
|
||||
|
||||
if not active_configs:
|
||||
return MCPInitSummary(total=0, success=0, failed=[])
|
||||
|
||||
logger.info(f"等待 {len(active_configs)} 个 MCP 服务初始化...")
|
||||
|
||||
init_tasks = [
|
||||
asyncio.create_task(
|
||||
self._start_mcp_server(
|
||||
name=name,
|
||||
cfg=cfg,
|
||||
shutdown_event=shutdown_event,
|
||||
init_timeout=init_timeout,
|
||||
),
|
||||
name=f"mcp-init:{name}",
|
||||
)
|
||||
for (name, cfg, shutdown_event) in active_configs
|
||||
]
|
||||
results = await asyncio.gather(*init_tasks, return_exceptions=True)
|
||||
|
||||
success_count = 0
|
||||
failed_services: list[str] = []
|
||||
|
||||
for (name, cfg, _), result in zip(active_configs, results, strict=False):
|
||||
if isinstance(result, Exception):
|
||||
if isinstance(result, MCPInitTimeoutError):
|
||||
logger.error(
|
||||
f"Connected to MCP server {name} timeout ({timeout_display} seconds)"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Failed to initialize MCP server {name}: {result}")
|
||||
self._log_safe_mcp_debug_config(cfg)
|
||||
failed_services.append(name)
|
||||
async with self._runtime_lock:
|
||||
self._mcp_server_runtime.pop(name, None)
|
||||
continue
|
||||
|
||||
success_count += 1
|
||||
|
||||
if failed_services:
|
||||
logger.warning(
|
||||
f"The following MCP services failed to initialize: {', '.join(failed_services)}. "
|
||||
f"Please check the mcp_server.json file and server availability."
|
||||
)
|
||||
|
||||
summary = MCPInitSummary(
|
||||
total=len(active_configs), success=success_count, failed=failed_services
|
||||
)
|
||||
logger.info(
|
||||
f"MCP services initialization completed: {summary.success}/{summary.total} successful, {len(summary.failed)} failed."
|
||||
)
|
||||
if summary.total > 0 and summary.success == 0:
|
||||
msg = "All MCP services failed to initialize, please check the mcp_server.json and server availability."
|
||||
if raise_on_all_failed:
|
||||
raise MCPAllServicesFailedError(msg)
|
||||
logger.error(msg)
|
||||
return summary
|
||||
|
||||
async def _start_mcp_server(
|
||||
self,
|
||||
name: str,
|
||||
cfg: dict,
|
||||
*,
|
||||
shutdown_event: asyncio.Event | None = None,
|
||||
init_timeout: float,
|
||||
) -> None:
|
||||
"""Start an MCP server with timeout."""
|
||||
async with self._runtime_lock:
|
||||
if name in self._mcp_server_runtime or name in self._mcp_starting:
|
||||
logger.warning(
|
||||
f"Connected to MCP server {name}, ignoring this startup request (timeout={init_timeout:g})."
|
||||
)
|
||||
self._log_safe_mcp_debug_config(cfg)
|
||||
return
|
||||
self._mcp_starting.add(name)
|
||||
|
||||
if shutdown_event is None:
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
mcp_client: Any = None
|
||||
try:
|
||||
mcp_client = await asyncio.wait_for(
|
||||
self._init_mcp_client(name, cfg),
|
||||
timeout=init_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise MCPInitTimeoutError(
|
||||
f"Connected to MCP server {name} timeout ({init_timeout:g} seconds)"
|
||||
) from exc
|
||||
except Exception:
|
||||
logger.error(f"Failed to initialize MCP client {name}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
if mcp_client is None:
|
||||
async with self._runtime_lock:
|
||||
self._mcp_starting.discard(name)
|
||||
|
||||
async def lifecycle() -> None:
|
||||
try:
|
||||
await shutdown_event.wait()
|
||||
logger.info(f"Received shutdown signal for MCP client {name}")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"MCP client {name} task was cancelled")
|
||||
raise
|
||||
finally:
|
||||
await self._terminate_mcp_client(name)
|
||||
|
||||
lifecycle_task = asyncio.create_task(lifecycle(), name=f"mcp-client:{name}")
|
||||
async with self._runtime_lock:
|
||||
self._mcp_server_runtime[name] = _MCPServerRuntime(
|
||||
name=name,
|
||||
client=mcp_client,
|
||||
shutdown_event=shutdown_event,
|
||||
lifecycle_task=lifecycle_task,
|
||||
)
|
||||
self._mcp_starting.discard(name)
|
||||
|
||||
async def _shutdown_runtimes(
|
||||
self,
|
||||
runtimes: list[_MCPServerRuntime],
|
||||
shutdown_timeout: float,
|
||||
*,
|
||||
strict: bool = True,
|
||||
) -> list[str]:
|
||||
"""Shutdown runtimes and wait for lifecycle tasks."""
|
||||
lifecycle_tasks = [
|
||||
runtime.lifecycle_task
|
||||
for runtime in runtimes
|
||||
if not runtime.lifecycle_task.done()
|
||||
]
|
||||
if not lifecycle_tasks:
|
||||
return []
|
||||
|
||||
for runtime in runtimes:
|
||||
runtime.shutdown_event.set()
|
||||
|
||||
try:
|
||||
results = await asyncio.wait_for(
|
||||
asyncio.gather(*lifecycle_tasks, return_exceptions=True),
|
||||
timeout=shutdown_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pending_names = [
|
||||
runtime.name
|
||||
for runtime in runtimes
|
||||
if not runtime.lifecycle_task.done()
|
||||
]
|
||||
for task in lifecycle_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(*lifecycle_tasks, return_exceptions=True)
|
||||
if strict:
|
||||
raise MCPShutdownTimeoutError(pending_names, shutdown_timeout)
|
||||
logger.warning(
|
||||
"MCP server shutdown timeout (%s seconds), the following servers were not fully closed: %s",
|
||||
f"{shutdown_timeout:g}",
|
||||
", ".join(pending_names),
|
||||
)
|
||||
return pending_names
|
||||
else:
|
||||
for result in results:
|
||||
if isinstance(result, asyncio.CancelledError):
|
||||
logger.debug("MCP lifecycle task was cancelled during shutdown.")
|
||||
elif isinstance(result, Exception):
|
||||
logger.error(
|
||||
"MCP lifecycle task failed during shutdown.",
|
||||
exc_info=(type(result), result, result.__traceback__),
|
||||
)
|
||||
return []
|
||||
|
||||
async def _cleanup_mcp_client_safely(
|
||||
self, mcp_client: Any, name: str
|
||||
) -> None:
|
||||
"""Safely cleanup an MCP client."""
|
||||
try:
|
||||
await mcp_client.cleanup()
|
||||
except Exception as cleanup_exc:
|
||||
logger.error(
|
||||
f"Failed to cleanup MCP client resources {name}: {cleanup_exc}"
|
||||
)
|
||||
|
||||
async def _init_mcp_client(self, name: str, config: dict) -> Any:
|
||||
"""Initialize a single MCP client."""
|
||||
mcp_mod = _get_mcp_client()
|
||||
MCPClient = mcp_mod.MCPClient
|
||||
mcp_tool_mod = _get_mcp_tool()
|
||||
MCPTool = mcp_tool_mod.MCPTool
|
||||
|
||||
mcp_client = MCPClient()
|
||||
mcp_client.name = name
|
||||
try:
|
||||
await mcp_client.connect_to_server(config, name)
|
||||
tools_res = await mcp_client.list_tools_and_save()
|
||||
except asyncio.CancelledError:
|
||||
await self._cleanup_mcp_client_safely(mcp_client, name)
|
||||
raise
|
||||
except Exception:
|
||||
await self._cleanup_mcp_client_safely(mcp_client, name)
|
||||
raise
|
||||
logger.debug(f"MCP server {name} list tools response: {tools_res}")
|
||||
tool_names = [tool.name for tool in tools_res.tools]
|
||||
|
||||
# Remove old MCP tools for this server
|
||||
self.func_list = [
|
||||
f
|
||||
for f in self.func_list
|
||||
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
|
||||
]
|
||||
|
||||
# Add new MCP tools
|
||||
for tool in mcp_client.tools:
|
||||
func_tool = MCPTool(
|
||||
mcp_tool=tool,
|
||||
mcp_client=mcp_client,
|
||||
mcp_server_name=name,
|
||||
)
|
||||
self.func_list.append(func_tool)
|
||||
|
||||
logger.info(f"Connected to MCP server {name}, Tools: {tool_names}")
|
||||
return mcp_client
|
||||
|
||||
async def _terminate_mcp_client(self, name: str) -> None:
|
||||
"""Terminate and cleanup an MCP client."""
|
||||
async with self._runtime_lock:
|
||||
runtime = self._mcp_server_runtime.get(name)
|
||||
if runtime:
|
||||
client = runtime.client
|
||||
await self._cleanup_mcp_client_safely(client, name)
|
||||
self.func_list = [
|
||||
f
|
||||
for f in self.func_list
|
||||
if not (isinstance(f, _get_mcp_tool().MCPTool) and f.mcp_server_name == name)
|
||||
]
|
||||
async with self._runtime_lock:
|
||||
self._mcp_server_runtime.pop(name, None)
|
||||
self._mcp_starting.discard(name)
|
||||
logger.info(f"Disconnected from MCP server {name}")
|
||||
return
|
||||
|
||||
self.func_list = [
|
||||
f
|
||||
for f in self.func_list
|
||||
if not (isinstance(f, _get_mcp_tool().MCPTool) and f.mcp_server_name == name)
|
||||
]
|
||||
async with self._runtime_lock:
|
||||
self._mcp_starting.discard(name)
|
||||
|
||||
async def test_mcp_server_connection(self, config: dict) -> list[str]:
|
||||
"""Test connection to an MCP server."""
|
||||
mcp_mod = _get_mcp_client()
|
||||
MCPClient = mcp_mod.MCPClient
|
||||
_prepare_config = mcp_mod._prepare_config
|
||||
_quick_test_mcp_connection = mcp_mod._quick_test_mcp_connection
|
||||
|
||||
if "url" in config:
|
||||
cfg = _prepare_config(config.copy())
|
||||
success, error_msg = await _quick_test_mcp_connection(cfg)
|
||||
if not success:
|
||||
raise Exception(error_msg)
|
||||
|
||||
mcp_client = MCPClient()
|
||||
try:
|
||||
logger.debug(f"testing MCP server connection with config: {config}")
|
||||
await mcp_client.connect_to_server(config, "test")
|
||||
tools_res = await mcp_client.list_tools_and_save()
|
||||
tool_names = [tool.name for tool in tools_res.tools]
|
||||
finally:
|
||||
logger.debug("Cleaning up MCP client after testing connection.")
|
||||
await mcp_client.cleanup()
|
||||
return tool_names
|
||||
|
||||
async def enable_mcp_server(
|
||||
self,
|
||||
name: str,
|
||||
config: dict,
|
||||
shutdown_event: asyncio.Event | None = None,
|
||||
init_timeout: float | str | None = None,
|
||||
) -> None:
|
||||
"""Enable and initialize an MCP server."""
|
||||
if init_timeout is None:
|
||||
timeout_value = self._enable_timeout_default
|
||||
else:
|
||||
timeout_value = _resolve_timeout(
|
||||
timeout=init_timeout,
|
||||
env_name=ENABLE_MCP_TIMEOUT_ENV,
|
||||
default=self._enable_timeout_default,
|
||||
)
|
||||
await self._start_mcp_server(
|
||||
name=name,
|
||||
cfg=config,
|
||||
shutdown_event=shutdown_event,
|
||||
init_timeout=timeout_value,
|
||||
)
|
||||
|
||||
async def disable_mcp_server(
|
||||
self,
|
||||
name: str | None = None,
|
||||
shutdown_timeout: float = 10,
|
||||
) -> None:
|
||||
"""Disable an MCP server by name, or all if name is None."""
|
||||
if name:
|
||||
async with self._runtime_lock:
|
||||
runtime = self._mcp_server_runtime.get(name)
|
||||
if runtime is None:
|
||||
return
|
||||
await self._shutdown_runtimes([runtime], shutdown_timeout, strict=True)
|
||||
else:
|
||||
async with self._runtime_lock:
|
||||
runtimes = list(self._mcp_server_runtime.values())
|
||||
await self._shutdown_runtimes(runtimes, shutdown_timeout, strict=False)
|
||||
|
||||
def _warn_on_timeout_mismatch(
|
||||
self,
|
||||
init_timeout: float,
|
||||
enable_timeout: float,
|
||||
) -> None:
|
||||
if init_timeout == enable_timeout:
|
||||
return
|
||||
with self._timeout_warn_lock:
|
||||
if self._timeout_mismatch_warned:
|
||||
return
|
||||
logger.info(
|
||||
"检测到 MCP 初始化超时与动态启用超时配置不同:"
|
||||
"初始化使用 %s 秒,动态启用使用 %s 秒。如需一致,请设置相同值。",
|
||||
f"{init_timeout:g}",
|
||||
f"{enable_timeout:g}",
|
||||
)
|
||||
self._timeout_mismatch_warned = True
|
||||
|
||||
def get_func_desc_openai_style(self, omit_empty_parameter_field=False) -> list:
|
||||
"""Get OpenAI-style function descriptions for active tools."""
|
||||
tools = [f for f in self.func_list if f.active]
|
||||
toolset = ToolSet(tools)
|
||||
return toolset.openai_schema(
|
||||
omit_empty_parameter_field=omit_empty_parameter_field,
|
||||
)
|
||||
|
||||
def get_func_desc_anthropic_style(self) -> list:
|
||||
"""Get Anthropic-style function descriptions for active tools."""
|
||||
tools = [f for f in self.func_list if f.active]
|
||||
toolset = ToolSet(tools)
|
||||
return toolset.anthropic_schema()
|
||||
|
||||
def get_func_desc_google_genai_style(self) -> dict:
|
||||
"""Get Google GenAI-style function descriptions for active tools."""
|
||||
tools = [f for f in self.func_list if f.active]
|
||||
toolset = ToolSet(tools)
|
||||
return toolset.google_schema()
|
||||
|
||||
def deactivate_llm_tool(self, name: str) -> bool:
|
||||
"""Deactivate a registered function tool."""
|
||||
func_tool = self.get_func(name)
|
||||
if func_tool is not None:
|
||||
func_tool.active = False
|
||||
|
||||
inactivated_llm_tools: list = sp.get(
|
||||
"inactivated_llm_tools",
|
||||
[],
|
||||
scope="global",
|
||||
scope_id="global",
|
||||
)
|
||||
if name not in inactivated_llm_tools:
|
||||
inactivated_llm_tools.append(name)
|
||||
sp.put(
|
||||
"inactivated_llm_tools",
|
||||
inactivated_llm_tools,
|
||||
scope="global",
|
||||
scope_id="global",
|
||||
)
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
def activate_llm_tool(self, name: str, star_map: dict) -> bool:
|
||||
"""Activate a registered function tool."""
|
||||
func_tool = self.get_func(name)
|
||||
if func_tool is not None:
|
||||
if func_tool.handler_module_path in star_map:
|
||||
if not star_map[func_tool.handler_module_path].activated:
|
||||
raise ValueError(
|
||||
f"此函数调用工具所属的插件 {star_map[func_tool.handler_module_path].name} 已被禁用,请先在管理面板启用再激活此工具。"
|
||||
)
|
||||
|
||||
func_tool.active = True
|
||||
|
||||
inactivated_llm_tools: list = sp.get(
|
||||
"inactivated_llm_tools",
|
||||
[],
|
||||
scope="global",
|
||||
scope_id="global",
|
||||
)
|
||||
if name in inactivated_llm_tools:
|
||||
inactivated_llm_tools.remove(name)
|
||||
sp.put(
|
||||
"inactivated_llm_tools",
|
||||
inactivated_llm_tools,
|
||||
scope="global",
|
||||
scope_id="global",
|
||||
)
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def mcp_config_path(self) -> str:
|
||||
data_dir = get_astrbot_data_path()
|
||||
return os.path.join(data_dir, "mcp_server.json")
|
||||
|
||||
def load_mcp_config(self) -> dict:
|
||||
"""Load MCP configuration from file."""
|
||||
if not os.path.exists(self.mcp_config_path):
|
||||
os.makedirs(os.path.dirname(self.mcp_config_path), exist_ok=True)
|
||||
with open(self.mcp_config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
|
||||
return DEFAULT_MCP_CONFIG.copy()
|
||||
|
||||
try:
|
||||
with open(self.mcp_config_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"加载 MCP 配置失败: {e}")
|
||||
return DEFAULT_MCP_CONFIG.copy()
|
||||
|
||||
def save_mcp_config(self, config: dict) -> bool:
|
||||
"""Save MCP configuration to file."""
|
||||
try:
|
||||
with open(self.mcp_config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=4)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"保存 MCP 配置失败: {e}")
|
||||
return False
|
||||
|
||||
# Module paths for built-in tool providers
|
||||
_INTERNAL_TOOL_PROVIDERS: ClassVar[list[str]] = [
|
||||
"astrbot.core.tools.cron_tools",
|
||||
"astrbot.core.tools.kb_query",
|
||||
"astrbot.core.tools.send_message",
|
||||
"astrbot.core.computer.computer_tool_provider",
|
||||
]
|
||||
|
||||
def register_internal_tools(self) -> None:
|
||||
"""Register AstrBot built-in tools from all internal providers.
|
||||
|
||||
Each provider module should expose a get_all_tools() function.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
existing_names = {t.name for t in self.func_list}
|
||||
|
||||
for module_path in self._INTERNAL_TOOL_PROVIDERS:
|
||||
try:
|
||||
mod = importlib.import_module(module_path)
|
||||
provider_tools = mod.get_all_tools()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to load internal tool provider %s: %s",
|
||||
module_path,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
|
||||
for tool in provider_tools:
|
||||
tool.source = "internal"
|
||||
if tool.name not in existing_names:
|
||||
self.func_list.append(tool)
|
||||
existing_names.add(tool.name)
|
||||
logger.info("Registered internal tool: %s", tool.name)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.func_list)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self.func_list)
|
||||
|
||||
|
||||
# Alias for backward compatibility
|
||||
FuncCall = FunctionToolManager
|
||||
@@ -13,6 +13,9 @@ Modules:
|
||||
|
||||
from astrbot import logger
|
||||
|
||||
# Tool API
|
||||
from astrbot._internal.tools.base import FunctionTool, ToolSet
|
||||
|
||||
# MCP API
|
||||
from astrbot.api.mcp import (
|
||||
MCPClient,
|
||||
@@ -30,18 +33,15 @@ from astrbot.api.skills import (
|
||||
skill_to_tool,
|
||||
)
|
||||
|
||||
# Tools API
|
||||
# Tools API (public interface)
|
||||
from astrbot.api.tools import ToolRegistry, get_registry, tool
|
||||
from astrbot.core import html_renderer, sp
|
||||
from astrbot.core.agent.tool import FunctionTool, ToolSet
|
||||
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.star.register import register_agent as agent
|
||||
from astrbot.core.star.register import register_llm_tool as llm_tool
|
||||
|
||||
__all__ = [
|
||||
"AstrBotConfig",
|
||||
"BaseFunctionToolExecutor",
|
||||
"FunctionTool",
|
||||
"MCPClient",
|
||||
"MCPTool",
|
||||
|
||||
+9
-5
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
MCP (Model Context Protocol) Public API for AstrBot.
|
||||
|
||||
This module provides a simple, stable interface for MCP server management,
|
||||
delegating to the _internal package.
|
||||
|
||||
Example:
|
||||
from astrbot.api.mcp import get_mcp_servers, register_mcp_server
|
||||
|
||||
@@ -26,8 +29,9 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from astrbot.core.agent.mcp_client import MCPClient, MCPTool
|
||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||
# Import from _internal package (the canonical source)
|
||||
from astrbot._internal.mcp.client import MCPClient
|
||||
from astrbot._internal.mcp.tool import MCPTool
|
||||
|
||||
__all__ = [
|
||||
"MCPClient",
|
||||
@@ -42,7 +46,7 @@ def get_mcp_servers() -> dict[str, MCPClient]:
|
||||
"""Get all connected MCP servers."""
|
||||
from astrbot.core.provider.register import llm_tools as func_tool_manager
|
||||
|
||||
manager: FunctionToolManager = func_tool_manager
|
||||
manager = func_tool_manager
|
||||
return dict(manager.mcp_client_dict)
|
||||
|
||||
|
||||
@@ -69,7 +73,7 @@ async def register_mcp_server(
|
||||
"""
|
||||
from astrbot.core.provider.register import llm_tools as func_tool_manager
|
||||
|
||||
manager: FunctionToolManager = func_tool_manager
|
||||
manager = func_tool_manager
|
||||
|
||||
config: dict[str, Any] = {}
|
||||
if command is not None:
|
||||
@@ -89,5 +93,5 @@ async def unregister_mcp_server(name: str) -> None:
|
||||
"""Disconnect and remove an MCP server."""
|
||||
from astrbot.core.provider.register import llm_tools as func_tool_manager
|
||||
|
||||
manager: FunctionToolManager = func_tool_manager
|
||||
manager = func_tool_manager
|
||||
await manager.disable_mcp_server(name=name)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
Skills Public API for AstrBot.
|
||||
|
||||
This module provides a simple, stable interface for skill management,
|
||||
delegating to the _internal package.
|
||||
|
||||
Two skill types:
|
||||
1. Prompt-based: SKILL.md files injected into system prompt
|
||||
2. Tool-based: Skills with input_schema converted to FunctionTool
|
||||
@@ -20,8 +23,9 @@ Example:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
from astrbot.core.skills.skill_manager import SkillInfo, SkillManager
|
||||
# Import from _internal package (the canonical source)
|
||||
from astrbot._internal.skills.manager import SkillInfo, SkillManager
|
||||
from astrbot._internal.tools.base import FunctionTool
|
||||
|
||||
__all__ = ["SkillInfo", "SkillManager", "get_skill_manager", "skill_to_tool"]
|
||||
|
||||
|
||||
+14
-4
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
Tools Public API for AstrBot.
|
||||
|
||||
This module provides a simple, stable interface for tool registration
|
||||
and management. All implementations are delegated to the _internal package.
|
||||
|
||||
Example:
|
||||
from astrbot.api.tools import tool, get_registry
|
||||
|
||||
@@ -18,20 +21,27 @@ from collections.abc import Awaitable, Callable
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
# Import from _internal package (the canonical source)
|
||||
from astrbot._internal.tools.base import FunctionTool, ToolSet
|
||||
from astrbot._internal.tools.registry import FunctionToolManager
|
||||
|
||||
__all__ = ["FunctionTool", "ToolRegistry", "get_registry", "tool"]
|
||||
__all__ = ["FunctionTool", "ToolRegistry", "ToolSet", "get_registry", "tool"]
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Simple wrapper around FunctionToolManager for tool registration."""
|
||||
"""Wrapper around FunctionToolManager for simplified tool registration.
|
||||
|
||||
This class provides a user-friendly interface for registering and
|
||||
managing tools, delegating to the internal FunctionToolManager.
|
||||
"""
|
||||
|
||||
_instance: ToolRegistry | None = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Import here to avoid circular imports
|
||||
from astrbot.core.provider.register import llm_tools as func_tool_manager
|
||||
|
||||
self._manager = func_tool_manager
|
||||
self._manager: FunctionToolManager = func_tool_manager
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> ToolRegistry:
|
||||
|
||||
@@ -7,7 +7,7 @@ import click
|
||||
from click.shell_completion import get_completion_class
|
||||
|
||||
from . import __version__
|
||||
from .commands import bk, conf, init, plug, run, tui, uninstall
|
||||
from .commands import bk, conf, dev, init, plug, run, tui, uninstall
|
||||
from .i18n import t
|
||||
|
||||
logo_tmpl = r"""
|
||||
@@ -86,6 +86,7 @@ cli.add_command(conf)
|
||||
cli.add_command(uninstall)
|
||||
cli.add_command(bk)
|
||||
cli.add_command(tui)
|
||||
cli.add_command(dev)
|
||||
|
||||
|
||||
@click.command()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from .cmd_bk import bk
|
||||
from .cmd_conf import conf
|
||||
from .cmd_dev import dev
|
||||
from .cmd_init import init
|
||||
from .cmd_plug import plug
|
||||
from .cmd_run import run
|
||||
from .cmd_tui import tui
|
||||
from .cmd_uninstall import uninstall
|
||||
|
||||
__all__ = ["bk", "conf", "init", "plug", "run", "tui", "uninstall"]
|
||||
__all__ = ["bk", "conf", "dev", "init", "plug", "run", "tui", "uninstall"]
|
||||
|
||||
@@ -14,19 +14,11 @@ MCP client - DEPRECATED
|
||||
This file exists solely for backward compatibility and will be removed in a future version.
|
||||
"""
|
||||
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"astrbot.core.agent.mcp_client has been moved to astrbot._internal.mcp. "
|
||||
"Please update your imports.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from contextlib import AsyncExitStack
|
||||
from datetime import timedelta
|
||||
from typing import Generic
|
||||
@@ -46,6 +38,12 @@ from astrbot.core.utils.log_pipe import LogPipe
|
||||
from .run_context import TContext
|
||||
from .tool import FunctionTool
|
||||
|
||||
warnings.warn(
|
||||
"astrbot.core.agent.mcp_client has been moved to astrbot._internal.mcp. "
|
||||
"Please update your imports.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
try:
|
||||
import anyio
|
||||
import mcp
|
||||
@@ -246,7 +244,7 @@ class MCPClient:
|
||||
mcp.ClientSession(
|
||||
*streams,
|
||||
read_timeout_seconds=read_timeout,
|
||||
logging_callback=logging_callback, # type: ignore
|
||||
logging_callback=logging_callback,
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -272,7 +270,7 @@ class MCPClient:
|
||||
read_stream=read_s,
|
||||
write_stream=write_s,
|
||||
read_timeout_seconds=read_timeout,
|
||||
logging_callback=logging_callback, # type: ignore
|
||||
logging_callback=logging_callback,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -303,7 +301,7 @@ class MCPClient:
|
||||
logger=logger,
|
||||
identifier=f"MCPServer-{name}",
|
||||
callback=callback,
|
||||
), # type: ignore
|
||||
),
|
||||
),
|
||||
)
|
||||
self.process_pid = self._extract_stdio_process_pid(self._streams_context)
|
||||
|
||||
@@ -771,7 +771,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
|
||||
_final_resp: CallToolResult | None = None
|
||||
async for resp in self._iter_tool_executor_results(executor): # type: ignore
|
||||
async for resp in self._iter_tool_executor_results(executor):
|
||||
if isinstance(resp, CallToolResult):
|
||||
res = resp
|
||||
_final_resp = resp
|
||||
@@ -913,7 +913,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
contexts: list[dict[str, Any]] = []
|
||||
for msg in self.run_context.messages:
|
||||
if hasattr(msg, "model_dump"):
|
||||
contexts.append(msg.model_dump()) # type: ignore[call-arg]
|
||||
contexts.append(msg.model_dump())
|
||||
elif isinstance(msg, dict):
|
||||
contexts.append(copy.deepcopy(msg))
|
||||
instruction = (
|
||||
|
||||
@@ -229,7 +229,7 @@ class ToolSet:
|
||||
if (
|
||||
tool.parameters and tool.parameters.get("properties")
|
||||
) or not omit_empty_parameter_field:
|
||||
func_def["function"]["parameters"] = tool.parameters # type: ignore[index]
|
||||
func_def["function"]["parameters"] = tool.parameters
|
||||
|
||||
result.append(func_def)
|
||||
return result
|
||||
|
||||
@@ -182,7 +182,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
if not isinstance(msg, dict):
|
||||
return f"error: messages[{idx}] should be an object."
|
||||
|
||||
msg_dict: dict[str, Any] = msg # type: ignore[assignment]
|
||||
msg_dict: dict[str, Any] = msg
|
||||
msg_type = str(msg_dict.get("type", "")).lower()
|
||||
if not msg_type:
|
||||
return f"error: messages[{idx}].type is required."
|
||||
|
||||
@@ -17,7 +17,12 @@ from astrbot.api import logger
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
|
||||
from astrbot.core.computer.olayer import (
|
||||
FileSystemComponent,
|
||||
PythonComponent,
|
||||
ShellComponent,
|
||||
)
|
||||
|
||||
from .base import ComputerBooter
|
||||
|
||||
|
||||
@@ -194,17 +199,17 @@ class BoxliteBooter(ComputerBooter):
|
||||
sb_url=f"http://127.0.0.1:{random_port}"
|
||||
)
|
||||
self._fs = ShipyardFileSystemComponent(
|
||||
client=self.mocked, # type: ignore
|
||||
client=self.mocked,
|
||||
ship_id=self.box.id,
|
||||
session_id=session_id,
|
||||
)
|
||||
self._python = ShipyardPythonComponent(
|
||||
client=self.mocked, # type: ignore
|
||||
client=self.mocked,
|
||||
ship_id=self.box.id,
|
||||
session_id=session_id,
|
||||
)
|
||||
self._shell = ShipyardShellComponent(
|
||||
client=self.mocked, # type: ignore
|
||||
client=self.mocked,
|
||||
ship_id=self.box.id,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
@@ -389,7 +389,7 @@ class SessionProjectRelation(SQLModel, table=True):
|
||||
class CommandConfig(TimestampMixin, SQLModel, table=True):
|
||||
"""Per-command configuration overrides for dashboard management."""
|
||||
|
||||
__tablename__ = "command_configs" # type: ignore
|
||||
__tablename__ = "command_configs"
|
||||
|
||||
handler_full_name: str = Field(
|
||||
primary_key=True,
|
||||
@@ -411,7 +411,7 @@ class CommandConfig(TimestampMixin, SQLModel, table=True):
|
||||
class CommandConflict(TimestampMixin, SQLModel, table=True):
|
||||
"""Conflict tracking for duplicated command names."""
|
||||
|
||||
__tablename__ = "command_conflicts" # type: ignore
|
||||
__tablename__ = "command_conflicts"
|
||||
|
||||
id: int | None = Field(
|
||||
default=None, primary_key=True, sa_column_kwargs={"autoincrement": True}
|
||||
|
||||
@@ -362,7 +362,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
col(Preference.scope_id).label("session_id"),
|
||||
func.json_extract(Preference.value, "$.val").label(
|
||||
"conversation_id",
|
||||
), # type: ignore
|
||||
),
|
||||
col(ConversationV2.persona_id).label("persona_id"),
|
||||
col(ConversationV2.title).label("title"),
|
||||
col(Persona.persona_id).label("persona_name"),
|
||||
|
||||
@@ -18,7 +18,7 @@ class BaseDocModel(SQLModel, table=False):
|
||||
class Document(BaseDocModel, table=True):
|
||||
"""SQLModel for documents table."""
|
||||
|
||||
__tablename__ = "documents" # type: ignore
|
||||
__tablename__ = "documents"
|
||||
|
||||
id: int | None = Field(
|
||||
default=None,
|
||||
@@ -46,7 +46,7 @@ class DocumentStorage:
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the SQLite database and create the documents table if it doesn't exist."""
|
||||
await self.connect()
|
||||
async with self.engine.begin() as conn: # type: ignore
|
||||
async with self.engine.begin() as conn:
|
||||
# Create tables using SQLModel
|
||||
await conn.run_sync(BaseDocModel.metadata.create_all)
|
||||
|
||||
@@ -89,15 +89,15 @@ class DocumentStorage:
|
||||
future=True,
|
||||
)
|
||||
self.async_session_maker = sessionmaker(
|
||||
self.engine, # type: ignore
|
||||
self.engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
) # type: ignore
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_session(self):
|
||||
"""Context manager for database sessions."""
|
||||
async with self.async_session_maker() as session: # type: ignore
|
||||
async with self.async_session_maker() as session:
|
||||
yield session
|
||||
|
||||
async def get_documents(
|
||||
@@ -172,7 +172,7 @@ class DocumentStorage:
|
||||
)
|
||||
session.add(document)
|
||||
await session.flush() # Flush to get the ID
|
||||
return document.id # type: ignore
|
||||
return document.id
|
||||
|
||||
async def insert_documents_batch(
|
||||
self,
|
||||
@@ -209,7 +209,7 @@ class DocumentStorage:
|
||||
session.add(document)
|
||||
|
||||
await session.flush() # Flush to get all IDs
|
||||
return [doc.id for doc in documents] # type: ignore
|
||||
return [doc.id for doc in documents]
|
||||
|
||||
async def delete_document_by_doc_id(self, doc_id: str) -> None:
|
||||
"""Delete a document by its doc_id.
|
||||
|
||||
@@ -14,7 +14,7 @@ class KnowledgeBase(BaseKBModel, table=True):
|
||||
存储知识库的基本信息和统计数据。
|
||||
"""
|
||||
|
||||
__tablename__ = "knowledge_bases" # type: ignore
|
||||
__tablename__ = "knowledge_bases"
|
||||
|
||||
id: int | None = Field(
|
||||
primary_key=True,
|
||||
@@ -62,7 +62,7 @@ class KBDocument(BaseKBModel, table=True):
|
||||
存储上传到知识库的文档元数据。
|
||||
"""
|
||||
|
||||
__tablename__ = "kb_documents" # type: ignore
|
||||
__tablename__ = "kb_documents"
|
||||
|
||||
id: int | None = Field(
|
||||
primary_key=True,
|
||||
@@ -96,7 +96,7 @@ class KBMedia(BaseKBModel, table=True):
|
||||
存储从文档中提取的图片、视频等多媒体资源。
|
||||
"""
|
||||
|
||||
__tablename__ = "kb_media" # type: ignore
|
||||
__tablename__ = "kb_media"
|
||||
|
||||
id: int | None = Field(
|
||||
primary_key=True,
|
||||
|
||||
@@ -433,7 +433,7 @@ class PersonaManager:
|
||||
user_turn = not user_turn
|
||||
|
||||
try:
|
||||
persona = Personality( # type: ignore[misc]
|
||||
persona = Personality(
|
||||
**persona_cfg,
|
||||
_begin_dialogs_processed=bd_processed,
|
||||
_mood_imitation_dialogs_processed="", # deprecated
|
||||
|
||||
@@ -60,7 +60,7 @@ async def run_third_party_agent(
|
||||
类似于 run_agent 函数,但专门处理第三方 agent runner
|
||||
"""
|
||||
try:
|
||||
async for resp in runner.step_until_done(max_step=30): # type: ignore[misc]
|
||||
async for resp in runner.step_until_done(max_step=30):
|
||||
if resp.type == "streaming_delta":
|
||||
if stream_to_general:
|
||||
continue
|
||||
|
||||
@@ -18,7 +18,7 @@ from astrbot.core.platform.astr_message_event import MessageSession
|
||||
from .misskey_api import MisskeyAPI
|
||||
|
||||
try:
|
||||
import magic # type: ignore
|
||||
import magic
|
||||
except Exception:
|
||||
magic = None
|
||||
|
||||
@@ -200,7 +200,7 @@ class MisskeyPlatformAdapter(Platform):
|
||||
try:
|
||||
if not isinstance(message.raw_message, dict):
|
||||
message.raw_message = {}
|
||||
raw_msg: dict[str, Any] = message.raw_message # type: ignore[assignment]
|
||||
raw_msg: dict[str, Any] = message.raw_message
|
||||
raw_msg["poll"] = poll
|
||||
message.__setattr__("poll", poll)
|
||||
except Exception:
|
||||
|
||||
@@ -35,7 +35,7 @@ def _patch_qq_botpy_formdata() -> None:
|
||||
"""
|
||||
|
||||
try:
|
||||
from botpy.http import _FormData # type: ignore
|
||||
from botpy.http import _FormData
|
||||
|
||||
if not hasattr(_FormData, "_is_processed"):
|
||||
setattr(_FormData, "_is_processed", False)
|
||||
@@ -276,7 +276,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
payload["content"] = plain_text or None
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_group_message(
|
||||
group_openid=source.group_openid, # type: ignore
|
||||
group_openid=source.group_openid,
|
||||
**retry_payload,
|
||||
),
|
||||
payload=payload,
|
||||
@@ -572,7 +572,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}")
|
||||
return None
|
||||
|
||||
return message.Message(**cast(dict[str, Any], result)) # type: ignore[arg-type]
|
||||
return message.Message(**cast(dict[str, Any], result))
|
||||
|
||||
@staticmethod
|
||||
async def _parse_to_qqofficial(message: MessageChain):
|
||||
|
||||
@@ -185,7 +185,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
payload["msg_seq"] = random.randint(1, 10000)
|
||||
if image_base64:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
|
||||
send_helper, # type: ignore
|
||||
send_helper,
|
||||
image_base64,
|
||||
QQOfficialMessageEvent.IMAGE_FILE_TYPE,
|
||||
group_openid=session.session_id,
|
||||
@@ -194,7 +194,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
payload["msg_type"] = 7
|
||||
if record_file_path:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
send_helper,
|
||||
record_file_path,
|
||||
QQOfficialMessageEvent.VOICE_FILE_TYPE,
|
||||
group_openid=session.session_id,
|
||||
@@ -204,7 +204,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
payload["msg_type"] = 7
|
||||
if video_file_source:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
send_helper,
|
||||
video_file_source,
|
||||
QQOfficialMessageEvent.VIDEO_FILE_TYPE,
|
||||
group_openid=session.session_id,
|
||||
@@ -215,7 +215,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
payload.pop("msg_id", None)
|
||||
if file_source:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
send_helper,
|
||||
file_source,
|
||||
QQOfficialMessageEvent.FILE_FILE_TYPE,
|
||||
file_name=file_name,
|
||||
@@ -241,7 +241,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
payload["msg_seq"] = random.randint(1, 10000)
|
||||
if image_base64:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
|
||||
send_helper, # type: ignore
|
||||
send_helper,
|
||||
image_base64,
|
||||
QQOfficialMessageEvent.IMAGE_FILE_TYPE,
|
||||
openid=session.session_id,
|
||||
@@ -250,7 +250,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
payload["msg_type"] = 7
|
||||
if record_file_path:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
send_helper,
|
||||
record_file_path,
|
||||
QQOfficialMessageEvent.VOICE_FILE_TYPE,
|
||||
openid=session.session_id,
|
||||
@@ -260,7 +260,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
payload["msg_type"] = 7
|
||||
if video_file_source:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
send_helper,
|
||||
video_file_source,
|
||||
QQOfficialMessageEvent.VIDEO_FILE_TYPE,
|
||||
openid=session.session_id,
|
||||
@@ -273,7 +273,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
payload.pop("msg_id", None)
|
||||
if file_source:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
send_helper,
|
||||
file_source,
|
||||
QQOfficialMessageEvent.FILE_FILE_TYPE,
|
||||
file_name=file_name,
|
||||
@@ -285,7 +285,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
payload.pop("msg_id", None)
|
||||
|
||||
ret = await QQOfficialMessageEvent.post_c2c_message(
|
||||
send_helper, # type: ignore
|
||||
send_helper,
|
||||
openid=session.session_id,
|
||||
**payload,
|
||||
)
|
||||
|
||||
@@ -158,7 +158,7 @@ class SatoriPlatformAdapter(Platform):
|
||||
|
||||
async for message in websocket:
|
||||
try:
|
||||
await self.handle_message(message) # type: ignore
|
||||
await self.handle_message(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Satori 处理消息异常: {e}")
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ class Prpcrypt:
|
||||
pkcs7 = PKCS7Encoder()
|
||||
text = pkcs7.encode(text)
|
||||
# 加密
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16]) # type: ignore
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
try:
|
||||
ciphertext = cryptor.encrypt(text)
|
||||
# 使用BASE64对加密后的字符串进行编码
|
||||
@@ -186,7 +186,7 @@ class Prpcrypt:
|
||||
@return: 删除填充补位后的明文
|
||||
"""
|
||||
try:
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16]) # type: ignore
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
# 使用BASE64对密文进行解码,然后AES-CBC解密
|
||||
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||
except Exception as e:
|
||||
@@ -259,7 +259,7 @@ class WXBizJsonMsgCrypt:
|
||||
# return:成功0,sEncryptMsg,失败返回对应的错误码None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)
|
||||
encrypt = encrypt.decode("utf-8") # type: ignore
|
||||
encrypt = encrypt.decode("utf-8")
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if timestamp is None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ from astrbot.core.provider.register import register_provider_adapter
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
try:
|
||||
import genie_tts as genie # type: ignore
|
||||
import genie_tts as genie
|
||||
except ImportError:
|
||||
genie = None
|
||||
|
||||
|
||||
@@ -18,6 +18,6 @@ async def extract_file_moonshotai(file_path: str, api_key: str) -> str:
|
||||
)
|
||||
file_object = await client.files.create(
|
||||
file=Path(file_path),
|
||||
purpose="file-extract", # type: ignore
|
||||
purpose="file-extract",
|
||||
)
|
||||
return (await client.files.content(file_id=file_object.id)).text
|
||||
|
||||
@@ -44,8 +44,8 @@ class SharedPreferences:
|
||||
scope: str,
|
||||
scope_id: str,
|
||||
key: str,
|
||||
default: _VT = None, # type: ignore[invalid-parameter-default]
|
||||
) -> _VT:
|
||||
default: _VT | None = None,
|
||||
) -> _VT | None:
|
||||
"""获取指定范围和键的偏好设置"""
|
||||
if scope_id is not None and key is not None:
|
||||
result = await self.db_helper.get_preference(scope, scope_id, key)
|
||||
@@ -72,8 +72,8 @@ class SharedPreferences:
|
||||
self,
|
||||
umo: str,
|
||||
key: str,
|
||||
default: _VT = None, # type: ignore[invalid-parameter-default]
|
||||
) -> _VT: ...
|
||||
default: _VT | None = None,
|
||||
) -> _VT | None: ...
|
||||
|
||||
@overload
|
||||
async def session_get(
|
||||
@@ -103,8 +103,8 @@ class SharedPreferences:
|
||||
self,
|
||||
umo: str | None,
|
||||
key: str | None = None,
|
||||
default: _VT = None, # type: ignore[invalid-parameter-default]
|
||||
) -> _VT | list[Preference]:
|
||||
default: _VT | None = None,
|
||||
) -> _VT | None | list[Preference]:
|
||||
"""获取会话范围的偏好设置
|
||||
|
||||
Note: 当 umo 或者 key 为 None,时,返回 Preference 列表,其中的 value 属性是一个 dict,value["val"] 为值。
|
||||
@@ -117,13 +117,13 @@ class SharedPreferences:
|
||||
async def global_get(self, key: None, default: Any = None) -> list[Preference]: ...
|
||||
|
||||
@overload
|
||||
async def global_get(self, key: str, default: _VT = None) -> _VT: ... # type: ignore[invalid-parameter-default]
|
||||
async def global_get(self, key: str, default: _VT | None = None) -> _VT | None: ...
|
||||
|
||||
async def global_get(
|
||||
self,
|
||||
key: str | None,
|
||||
default: _VT = None, # type: ignore[invalid-parameter-default]
|
||||
) -> _VT | list[Preference]:
|
||||
default: _VT | None = None,
|
||||
) -> _VT | None | list[Preference]:
|
||||
"""获取全局范围的偏好设置
|
||||
|
||||
Note: 当 scope_id 或者 key 为 None,时,返回 Preference 列表,其中的 value 属性是一个 dict,value["val"] 为值。
|
||||
@@ -169,10 +169,10 @@ class SharedPreferences:
|
||||
def get(
|
||||
self,
|
||||
key: str,
|
||||
default: _VT = None, # type: ignore[invalid-parameter-default]
|
||||
default: _VT | None = None,
|
||||
scope: str | None = None,
|
||||
scope_id: str | None = "",
|
||||
) -> _VT:
|
||||
) -> _VT | None:
|
||||
"""获取偏好设置(已弃用)"""
|
||||
if scope_id == "":
|
||||
scope_id = "unknown"
|
||||
|
||||
@@ -71,13 +71,18 @@
|
||||
</transition-group>
|
||||
</div>
|
||||
|
||||
<!-- Text (Markdown) -->
|
||||
<MarkdownRender
|
||||
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
|
||||
custom-id="message-list" :custom-html-tags="['ref']"
|
||||
:content="normalizeMarkdownContent(renderPart.part.text)" :typewriter="false"
|
||||
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }"
|
||||
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"/>
|
||||
<!-- Text (Markdown) -->
|
||||
<MarkdownRender
|
||||
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
|
||||
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"
|
||||
custom-id="message-list"
|
||||
:custom-html-tags="['ref']"
|
||||
:content="normalizeMarkdownContent(renderPart.part.text)"
|
||||
:typewriter="false"
|
||||
class="markdown-content"
|
||||
:is-dark="isDark"
|
||||
:monaco-options="{ theme: isDark ? 'vs-dark' : 'vs-light' }"
|
||||
/>
|
||||
|
||||
<!-- Text (Markdown) -->
|
||||
<MarkdownRender
|
||||
|
||||
@@ -42,7 +42,6 @@ const handleInstall = (plugin) => {
|
||||
class="rounded-lg d-flex flex-column plugin-card"
|
||||
elevation="0"
|
||||
>
|
||||
|
||||
<v-card-text
|
||||
class="plugin-card-content"
|
||||
>
|
||||
@@ -51,7 +50,7 @@ const handleInstall = (plugin) => {
|
||||
:src="plugin?.logo || defaultPluginIcon"
|
||||
:alt="plugin.name"
|
||||
class="plugin-cover__image"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="plugin-info">
|
||||
@@ -130,7 +129,7 @@ const handleInstall = (plugin) => {
|
||||
icon="mdi-star"
|
||||
size="x-small"
|
||||
style="margin-right: 2px"
|
||||
></v-icon>
|
||||
/>
|
||||
<span>{{ plugin.stars }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +158,7 @@ const handleInstall = (plugin) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="plugin-stats"></div>
|
||||
<div class="plugin-stats" />
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
|
||||
@@ -65,55 +65,119 @@ const handlePinnedImgError = (e) => {
|
||||
:src="(typeof plugin.logo === 'string' && plugin.logo.trim()) ? plugin.logo : defaultPluginIcon"
|
||||
:alt="plugin.name"
|
||||
@error="handlePinnedImgError"
|
||||
/>
|
||||
>
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-card-text class="d-flex" style="gap:8px; padding:12px;">
|
||||
<v-tooltip location="top" :text="tm('buttons.viewDocs')">
|
||||
<v-card-text
|
||||
class="d-flex"
|
||||
style="gap:8px; padding:12px;"
|
||||
>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
:text="tm('buttons.viewDocs')"
|
||||
>
|
||||
<template #activator="{ props: a }">
|
||||
<v-btn v-bind="a" icon size="small" variant="tonal" color="info" @click.stop="$emit('view-readme', plugin)">
|
||||
<v-btn
|
||||
v-bind="a"
|
||||
icon
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
@click.stop="$emit('view-readme', plugin)"
|
||||
>
|
||||
<v-icon>mdi-book-open-page-variant</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" :text="tm('card.actions.pluginConfig')">
|
||||
<v-tooltip
|
||||
location="top"
|
||||
:text="tm('card.actions.pluginConfig')"
|
||||
>
|
||||
<template #activator="{ props: a }">
|
||||
<v-btn v-bind="a" icon size="small" variant="tonal" color="primary" @click.stop="$emit('open-config', plugin.name)">
|
||||
<v-btn
|
||||
v-bind="a"
|
||||
icon
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click.stop="$emit('open-config', plugin.name)"
|
||||
>
|
||||
<v-icon>mdi-cog</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" :text="tm('card.actions.reloadPlugin')">
|
||||
<v-tooltip
|
||||
location="top"
|
||||
:text="tm('card.actions.reloadPlugin')"
|
||||
>
|
||||
<template #activator="{ props: a }">
|
||||
<v-btn v-bind="a" icon size="small" variant="tonal" color="primary" @click.stop="$emit('reload', plugin.name)">
|
||||
<v-btn
|
||||
v-bind="a"
|
||||
icon
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click.stop="$emit('reload', plugin.name)"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" :text="tm('buttons.update')">
|
||||
<v-tooltip
|
||||
location="top"
|
||||
:text="tm('buttons.update')"
|
||||
>
|
||||
<template #activator="{ props: a }">
|
||||
<v-btn v-bind="a" icon size="small" variant="tonal" color="warning" @click.stop="$emit('update', plugin.name)">
|
||||
<v-btn
|
||||
v-bind="a"
|
||||
icon
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="warning"
|
||||
@click.stop="$emit('update', plugin.name)"
|
||||
>
|
||||
<v-icon>mdi-update</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" :text="tm('buttons.viewInfo')">
|
||||
<v-tooltip
|
||||
location="top"
|
||||
:text="tm('buttons.viewInfo')"
|
||||
>
|
||||
<template #activator="{ props: a }">
|
||||
<v-btn v-bind="a" icon size="small" variant="tonal" color="secondary" @click.stop="$emit('show-info', plugin)">
|
||||
<v-btn
|
||||
v-bind="a"
|
||||
icon
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
@click.stop="$emit('show-info', plugin)"
|
||||
>
|
||||
<v-icon>mdi-information</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" :text="tm('buttons.uninstall')">
|
||||
<v-tooltip
|
||||
location="top"
|
||||
:text="tm('buttons.uninstall')"
|
||||
>
|
||||
<template #activator="{ props: a }">
|
||||
<v-btn v-bind="a" icon size="small" variant="tonal" color="error" @click.stop="$emit('uninstall', plugin.name)" v-if="!plugin.reserved">
|
||||
<v-btn
|
||||
v-if="!plugin.reserved"
|
||||
v-bind="a"
|
||||
icon
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="error"
|
||||
@click.stop="$emit('uninstall', plugin.name)"
|
||||
>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -127,11 +191,13 @@ const handlePinnedImgError = (e) => {
|
||||
size="small"
|
||||
class="pinned-pin-btn"
|
||||
:color="isPinned ? 'primary' : 'secondary'"
|
||||
@click.stop="$emit('toggle-pin', plugin)"
|
||||
:title="isPinned ? tm('buttons.unpin') : tm('buttons.pin')"
|
||||
style="position:absolute; top:6px; right:6px; min-width:22px; width:22px; height:22px;"
|
||||
@click.stop="$emit('toggle-pin', plugin)"
|
||||
>
|
||||
<v-icon size="14">{{ isPinned ? 'mdi-pin' : 'mdi-pin-outline' }}</v-icon>
|
||||
<v-icon size="14">
|
||||
{{ isPinned ? 'mdi-pin' : 'mdi-pin-outline' }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,48 +1,89 @@
|
||||
<template>
|
||||
<v-card class="base-folder-card" :class="{ 'drag-over': isDragOver }" rounded="lg" @click="$emit('click')" @contextmenu.prevent="$emit('contextmenu', $event)"
|
||||
elevation="1" hover @dragover.prevent="handleDragOver" @dragleave="handleDragLeave" @drop.prevent="handleDrop">
|
||||
<v-card-text class="d-flex align-center pa-3">
|
||||
<v-icon size="40" color="amber-darken-2" class="mr-3">mdi-folder</v-icon>
|
||||
<div class="folder-info flex-grow-1 overflow-hidden">
|
||||
<div class="text-subtitle-1 font-weight-medium text-truncate">{{ folder.name }}</div>
|
||||
<div v-if="folder.description" class="text-body-2 text-medium-emphasis text-truncate">
|
||||
{{ folder.description }}
|
||||
</div>
|
||||
</div>
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon="mdi-dots-vertical" variant="text" size="small" v-bind="props" @click.stop />
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click.stop="$emit('open')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-folder-open</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ mergedLabels.open }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click.stop="$emit('rename')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-pencil</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ mergedLabels.rename }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click.stop="$emit('move')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-folder-move</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ mergedLabels.moveTo }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider class="my-1" />
|
||||
<v-list-item @click.stop="$emit('delete')" class="text-error">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small" color="error">mdi-delete</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ mergedLabels.delete }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-card
|
||||
class="base-folder-card"
|
||||
:class="{ 'drag-over': isDragOver }"
|
||||
rounded="lg"
|
||||
elevation="1"
|
||||
hover
|
||||
@click="$emit('click')"
|
||||
@contextmenu.prevent="$emit('contextmenu', $event)"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<v-card-text class="d-flex align-center pa-3">
|
||||
<v-icon
|
||||
size="40"
|
||||
color="amber-darken-2"
|
||||
class="mr-3"
|
||||
>
|
||||
mdi-folder
|
||||
</v-icon>
|
||||
<div class="folder-info flex-grow-1 overflow-hidden">
|
||||
<div class="text-subtitle-1 font-weight-medium text-truncate">
|
||||
{{ folder.name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="folder.description"
|
||||
class="text-body-2 text-medium-emphasis text-truncate"
|
||||
>
|
||||
{{ folder.description }}
|
||||
</div>
|
||||
</div>
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
icon="mdi-dots-vertical"
|
||||
variant="text"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click.stop="$emit('open')">
|
||||
<template #prepend>
|
||||
<v-icon size="small">
|
||||
mdi-folder-open
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ mergedLabels.open }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click.stop="$emit('rename')">
|
||||
<template #prepend>
|
||||
<v-icon size="small">
|
||||
mdi-pencil
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ mergedLabels.rename }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click.stop="$emit('move')">
|
||||
<template #prepend>
|
||||
<v-icon size="small">
|
||||
mdi-folder-move
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ mergedLabels.moveTo }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider class="my-1" />
|
||||
<v-list-item
|
||||
class="text-error"
|
||||
@click.stop="$emit('delete')"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon
|
||||
size="small"
|
||||
color="error"
|
||||
>
|
||||
mdi-delete
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ mergedLabels.delete }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<div class="folder-item-selector">
|
||||
<!-- Trigger area -->
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText))">
|
||||
<span
|
||||
v-if="!modelValue"
|
||||
style="color: rgb(var(--v-theme-primaryText))"
|
||||
>
|
||||
{{ labels.notSelected || "未选择" }}
|
||||
</span>
|
||||
|
||||
@@ -10,7 +13,12 @@
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
|
||||
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
@click="openDialog"
|
||||
>
|
||||
{{ labels.buttonText || "选择..." }}
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -26,7 +34,12 @@
|
||||
class="dialog-title d-flex align-center"
|
||||
:class="isCompactLayout ? 'py-3 px-4' : 'py-4 px-5'"
|
||||
>
|
||||
<v-icon class="mr-3" color="primary"> mdi-account-circle </v-icon>
|
||||
<v-icon
|
||||
class="mr-3"
|
||||
color="primary"
|
||||
>
|
||||
mdi-account-circle
|
||||
</v-icon>
|
||||
<span>{{ labels.dialogTitle || "选择项目" }}</span>
|
||||
</v-card-title>
|
||||
|
||||
@@ -35,12 +48,18 @@
|
||||
<v-card-text class="pa-0 selector-content">
|
||||
<div class="selector-layout">
|
||||
<!-- Left: folder tree -->
|
||||
<div v-if="!isCompactLayout" class="folder-sidebar">
|
||||
<div
|
||||
v-if="!isCompactLayout"
|
||||
class="folder-sidebar"
|
||||
>
|
||||
<div class="sidebar-header pa-3 pb-2">
|
||||
<span
|
||||
class="text-caption text-medium-emphasis font-weight-medium"
|
||||
>
|
||||
<v-icon size="small" class="mr-1">mdi-folder-multiple</v-icon>
|
||||
<v-icon
|
||||
size="small"
|
||||
class="mr-1"
|
||||
>mdi-folder-multiple</v-icon>
|
||||
文件夹
|
||||
</span>
|
||||
</div>
|
||||
@@ -85,7 +104,10 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-if="treeLoading" class="text-center pa-4">
|
||||
<div
|
||||
v-if="treeLoading"
|
||||
class="text-center pa-4"
|
||||
>
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
size="20"
|
||||
@@ -97,7 +119,10 @@
|
||||
|
||||
<!-- Right: items -->
|
||||
<div class="items-panel">
|
||||
<div v-if="isCompactLayout" class="mobile-folder-bar px-4 py-2">
|
||||
<div
|
||||
v-if="isCompactLayout"
|
||||
class="mobile-folder-bar px-4 py-2"
|
||||
>
|
||||
<v-btn
|
||||
icon="mdi-arrow-left"
|
||||
size="small"
|
||||
@@ -143,14 +168,20 @@
|
||||
v-if="(item as any).isRoot"
|
||||
size="small"
|
||||
class="mr-1"
|
||||
>mdi-home</v-icon
|
||||
>
|
||||
mdi-home
|
||||
</v-icon>
|
||||
{{ item.title }}
|
||||
</v-breadcrumbs-item>
|
||||
</template>
|
||||
|
||||
<template #divider>
|
||||
<v-icon size="small" color="grey">mdi-chevron-right</v-icon>
|
||||
<v-icon
|
||||
size="small"
|
||||
color="grey"
|
||||
>
|
||||
mdi-chevron-right
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
</div>
|
||||
@@ -191,9 +222,12 @@
|
||||
color="amber-lighten-4"
|
||||
class="mr-3"
|
||||
>
|
||||
<v-icon color="amber-darken-2" size="20"
|
||||
>mdi-folder</v-icon
|
||||
<v-icon
|
||||
color="amber-darken-2"
|
||||
size="20"
|
||||
>
|
||||
mdi-folder
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
@@ -202,9 +236,12 @@
|
||||
</v-list-item-title>
|
||||
|
||||
<template #append>
|
||||
<v-icon size="20" color="grey"
|
||||
>mdi-chevron-right</v-icon
|
||||
<v-icon
|
||||
size="20"
|
||||
color="grey"
|
||||
>
|
||||
mdi-chevron-right
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
@@ -226,8 +263,8 @@
|
||||
:class="{
|
||||
'selected-item': selectedItemId === getItemId(item),
|
||||
}"
|
||||
@click="selectItem(item)"
|
||||
:active="selectedItemId === getItemId(item)"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar
|
||||
@@ -270,8 +307,8 @@
|
||||
icon="mdi-pencil"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click.stop="handleEditItem(item)"
|
||||
:title="labels.editButton || 'Edit'"
|
||||
@click.stop="handleEditItem(item)"
|
||||
/>
|
||||
<v-icon
|
||||
v-if="selectedItemId === getItemId(item)"
|
||||
@@ -288,13 +325,16 @@
|
||||
<div
|
||||
v-if="
|
||||
currentSubFolders.length === 0 &&
|
||||
currentItems.length === 0
|
||||
currentItems.length === 0
|
||||
"
|
||||
class="empty-state text-center py-12"
|
||||
>
|
||||
<v-icon size="64" color="grey-lighten-2"
|
||||
>mdi-folder-open-outline</v-icon
|
||||
<v-icon
|
||||
size="64"
|
||||
color="grey-lighten-2"
|
||||
>
|
||||
mdi-folder-open-outline
|
||||
</v-icon>
|
||||
<p class="text-grey mt-4 text-body-2">
|
||||
{{
|
||||
labels.emptyFolder || labels.noItems || "此文件夹为空"
|
||||
@@ -320,14 +360,17 @@
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<v-btn variant="text" @click="cancelSelection">
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="cancelSelection"
|
||||
>
|
||||
{{ labels.cancelButton || "取消" }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="confirmSelection"
|
||||
:disabled="!selectedItemId"
|
||||
@click="confirmSelection"
|
||||
>
|
||||
{{ labels.confirmButton || "确认" }}
|
||||
</v-btn>
|
||||
|
||||
@@ -216,35 +216,46 @@ const viewChangelog = () => {
|
||||
|
||||
<template v-if="!marketMode">
|
||||
<v-tooltip location="left">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<div class="extension-switch-wrap" @click.stop>
|
||||
<div v-bind="tooltipProps" style="display:inline-flex; align-items:center;">
|
||||
<v-switch
|
||||
:model-value="extension.activated"
|
||||
color="success"
|
||||
density="compact"
|
||||
hide-details
|
||||
inset
|
||||
@update:model-value="toggleActivation"
|
||||
></v-switch>
|
||||
</div>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<div
|
||||
class="extension-switch-wrap"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
v-bind="tooltipProps"
|
||||
style="display:inline-flex; align-items:center;"
|
||||
>
|
||||
<v-switch
|
||||
:model-value="extension.activated"
|
||||
color="success"
|
||||
density="compact"
|
||||
hide-details
|
||||
inset
|
||||
@update:model-value="toggleActivation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-tooltip location="top" :text="pinned ? tm('buttons.unpin') : tm('buttons.pin')">
|
||||
<template #activator="{ props: pinProps }">
|
||||
<v-btn
|
||||
v-bind="pinProps"
|
||||
icon
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:color="pinned ? 'primary' : 'secondary'"
|
||||
class="ml-2"
|
||||
@click.stop="togglePin"
|
||||
>
|
||||
<v-icon size="18">{{ pinned ? 'mdi-pin' : 'mdi-pin-outline' }}</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
:text="pinned ? tm('buttons.unpin') : tm('buttons.pin')"
|
||||
>
|
||||
<template #activator="{ props: pinProps }">
|
||||
<v-btn
|
||||
v-bind="pinProps"
|
||||
icon
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:color="pinned ? 'primary' : 'secondary'"
|
||||
class="ml-2"
|
||||
@click.stop="togglePin"
|
||||
>
|
||||
<v-icon size="18">
|
||||
{{ pinned ? 'mdi-pin' : 'mdi-pin-outline' }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{
|
||||
extension.activated ? tm("buttons.disable") : tm("buttons.enable")
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
:src="imageSrc"
|
||||
:alt="alt"
|
||||
class="qr-code-image"
|
||||
/>
|
||||
<div v-else class="qr-code-empty">
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="qr-code-empty"
|
||||
>
|
||||
{{ emptyHint }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,103 +1,125 @@
|
||||
<template>
|
||||
<div class="storage-cleanup-panel">
|
||||
<div class="text-subtitle-1 font-weight-medium mb-1">
|
||||
{{ tm('system.cleanup.title') }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mb-4">
|
||||
{{ tm('system.cleanup.subtitle') }}
|
||||
</div>
|
||||
|
||||
<v-expansion-panels variant="accordion">
|
||||
<v-expansion-panel elevation="0" class="border rounded-lg">
|
||||
<v-expansion-panel-title class="py-4">
|
||||
<div class="d-flex align-center justify-space-between w-100 pr-4 ga-3">
|
||||
<div class="d-flex align-center ga-3">
|
||||
<v-icon color="warning">mdi-broom</v-icon>
|
||||
<div>
|
||||
<div class="font-weight-medium">{{ tm('system.cleanup.panel.title') }}</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ tm('system.cleanup.panel.subtitle', { size: formatBytes(storageStatus.total_bytes || 0) }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-chip size="small" color="warning" variant="tonal">
|
||||
{{ formatBytes(storageStatus.total_bytes || 0) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-expansion-panel-title>
|
||||
|
||||
<v-expansion-panel-text>
|
||||
<div class="d-flex flex-wrap ga-2 mb-4">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
:loading="statusLoading"
|
||||
@click="loadStorageStatus"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-refresh</v-icon>
|
||||
{{ tm('system.cleanup.refresh') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="warning"
|
||||
:loading="cleaningTarget === 'all'"
|
||||
@click="cleanupStorage('all')"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-broom</v-icon>
|
||||
{{ tm('system.cleanup.cleanAll') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-row dense>
|
||||
<v-col
|
||||
v-for="item in storageCards"
|
||||
:key="item.key"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-card variant="tonal" class="h-100">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-start justify-space-between ga-3">
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||
{{ item.subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
<v-icon :color="item.color">{{ item.icon }}</v-icon>
|
||||
</div>
|
||||
|
||||
<div class="text-h5 mt-4">
|
||||
{{ formatBytes(item.sizeBytes) }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis mt-1">
|
||||
{{ tm('system.cleanup.fileCount', { count: item.fileCount }) }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis mt-2 storage-cleanup-path">
|
||||
{{ item.path }}
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
class="mt-4"
|
||||
size="small"
|
||||
:color="item.color"
|
||||
:loading="cleaningTarget === item.key"
|
||||
@click="cleanupStorage(item.key)"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-delete-sweep-outline</v-icon>
|
||||
{{ item.buttonText }}
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
<div class="storage-cleanup-panel">
|
||||
<div class="text-subtitle-1 font-weight-medium mb-1">
|
||||
{{ tm('system.cleanup.title') }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mb-4">
|
||||
{{ tm('system.cleanup.subtitle') }}
|
||||
</div>
|
||||
|
||||
<v-expansion-panels variant="accordion">
|
||||
<v-expansion-panel
|
||||
elevation="0"
|
||||
class="border rounded-lg"
|
||||
>
|
||||
<v-expansion-panel-title class="py-4">
|
||||
<div class="d-flex align-center justify-space-between w-100 pr-4 ga-3">
|
||||
<div class="d-flex align-center ga-3">
|
||||
<v-icon color="warning">
|
||||
mdi-broom
|
||||
</v-icon>
|
||||
<div>
|
||||
<div class="font-weight-medium">
|
||||
{{ tm('system.cleanup.panel.title') }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ tm('system.cleanup.panel.subtitle', { size: formatBytes(storageStatus.total_bytes || 0) }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-chip
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ formatBytes(storageStatus.total_bytes || 0) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-expansion-panel-title>
|
||||
|
||||
<v-expansion-panel-text>
|
||||
<div class="d-flex flex-wrap ga-2 mb-4">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
:loading="statusLoading"
|
||||
@click="loadStorageStatus"
|
||||
>
|
||||
<v-icon class="mr-2">
|
||||
mdi-refresh
|
||||
</v-icon>
|
||||
{{ tm('system.cleanup.refresh') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="warning"
|
||||
:loading="cleaningTarget === 'all'"
|
||||
@click="cleanupStorage('all')"
|
||||
>
|
||||
<v-icon class="mr-2">
|
||||
mdi-broom
|
||||
</v-icon>
|
||||
{{ tm('system.cleanup.cleanAll') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-row dense>
|
||||
<v-col
|
||||
v-for="item in storageCards"
|
||||
:key="item.key"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-card
|
||||
variant="tonal"
|
||||
class="h-100"
|
||||
>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-start justify-space-between ga-3">
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||
{{ item.subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
<v-icon :color="item.color">
|
||||
{{ item.icon }}
|
||||
</v-icon>
|
||||
</div>
|
||||
|
||||
<div class="text-h5 mt-4">
|
||||
{{ formatBytes(item.sizeBytes) }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis mt-1">
|
||||
{{ tm('system.cleanup.fileCount', { count: item.fileCount }) }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis mt-2 storage-cleanup-path">
|
||||
{{ item.path }}
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
class="mt-4"
|
||||
size="small"
|
||||
:color="item.color"
|
||||
:loading="cleaningTarget === item.key"
|
||||
@click="cleanupStorage(item.key)"
|
||||
>
|
||||
<v-icon class="mr-2">
|
||||
mdi-delete-sweep-outline
|
||||
</v-icon>
|
||||
{{ item.buttonText }}
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* Lightweight SHA-256 helper utilities for the dashboard frontend.
|
||||
*
|
||||
* Behavior:
|
||||
* - Prefer Web Crypto API (browser / webworker): `crypto.subtle.digest`.
|
||||
* - If unavailable and running under Node.js, try to use Node's `crypto` module.
|
||||
* - If neither is available, functions throw a clear error (explicit failure is preferred
|
||||
* to returning a silently-wrong value).
|
||||
*
|
||||
* Exports:
|
||||
* - sha256ArrayBuffer(input: string): Promise<ArrayBuffer>
|
||||
* - sha256Hex(input: string): Promise<string>
|
||||
* - sha256Base64(input: string): Promise<string>
|
||||
* - sha256HexSync(input: string): string // sync helper only available when Node crypto is present; otherwise throws
|
||||
*
|
||||
* Notes:
|
||||
* - All async functions accept a UTF-8 string and return the digest for the UTF-8 bytes of that string.
|
||||
* - This file intentionally avoids adding third-party deps and uses standard platform crypto primitives.
|
||||
*/
|
||||
|
||||
type MaybeNodeCrypto = {
|
||||
createHash?: (algo: string) => { update: (data: any) => any; digest: (enc?: string) => any };
|
||||
webcrypto?: Crypto;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* Compute SHA-256 digest as an ArrayBuffer.
|
||||
* Throws a descriptive Error if no suitable crypto provider is found.
|
||||
*/
|
||||
export async function sha256ArrayBuffer(input: string): Promise<ArrayBuffer> {
|
||||
// Use Web Crypto if available (recommended for browsers)
|
||||
const subtle = (globalThis as any)?.crypto?.subtle;
|
||||
if (subtle && typeof subtle.digest === "function") {
|
||||
const enc = new TextEncoder();
|
||||
return await subtle.digest("SHA-256", enc.encode(input));
|
||||
}
|
||||
|
||||
// Fallback: attempt to use Node's crypto module dynamically
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment
|
||||
const nodeCrypto: MaybeNodeCrypto = (typeof require !== "undefined" ? require("crypto") : null);
|
||||
if (nodeCrypto) {
|
||||
// If Node has a WebCrypto-compatible `webcrypto`
|
||||
if ((nodeCrypto as any).webcrypto && typeof (nodeCrypto as any).webcrypto.subtle?.digest === "function") {
|
||||
const enc = new TextEncoder();
|
||||
return await (nodeCrypto as any).webcrypto.subtle.digest("SHA-256", enc.encode(input));
|
||||
}
|
||||
|
||||
// Otherwise use createHash -> Buffer -> ArrayBuffer
|
||||
if (typeof (nodeCrypto as any).createHash === "function") {
|
||||
const hashBuffer: Buffer = (nodeCrypto as any).createHash("sha256").update(input).digest();
|
||||
// Buffer is backed by an ArrayBuffer; ensure we return an ArrayBuffer slice that represents the bytes
|
||||
return hashBuffer.buffer.slice(hashBuffer.byteOffset, hashBuffer.byteOffset + hashBuffer.byteLength);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// fall through to error below; we'll throw a single clear error message
|
||||
}
|
||||
|
||||
// If we reach here, no supported crypto API was found
|
||||
throw new Error(
|
||||
"No suitable crypto implementation found for SHA-256. " +
|
||||
"This function requires the Web Crypto API (crypto.subtle) in browsers or the Node 'crypto' module."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an ArrayBuffer (or view) to a hex string.
|
||||
*/
|
||||
function bufferToHex(ab: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(ab);
|
||||
// map + join is fine for moderate-size inputs (SHA-256 is small)
|
||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an ArrayBuffer to base64 string (browser-safe).
|
||||
* Uses chunking to avoid call stack / argument length issues on some engines.
|
||||
*/
|
||||
function bufferToBase64(ab: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(ab);
|
||||
const chunkSize = 0x8000; // 32KB chunks
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const slice = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode.apply(null, Array.from(slice));
|
||||
}
|
||||
// btoa works on binary string in browsers; in Node, global btoa may not exist.
|
||||
if (typeof btoa === "function") {
|
||||
return btoa(binary);
|
||||
}
|
||||
// Node fallback
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const nodeBuffer = Buffer.from(bytes);
|
||||
return nodeBuffer.toString("base64");
|
||||
} catch (e) {
|
||||
throw new Error("Unable to convert ArrayBuffer to base64: no btoa and no Buffer available.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 and return hex string.
|
||||
*/
|
||||
export async function sha256Hex(input: string): Promise<string> {
|
||||
const ab = await sha256ArrayBuffer(input);
|
||||
return bufferToHex(ab);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 and return base64 string.
|
||||
*/
|
||||
export async function sha256Base64(input: string): Promise<string> {
|
||||
const ab = await sha256ArrayBuffer(input);
|
||||
return bufferToBase64(ab);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous SHA-256 hex helper.
|
||||
* Only works when a synchronous provider (Node's `crypto.createHash`) is available.
|
||||
* Throws if not available.
|
||||
*/
|
||||
export function sha256HexSync(input: string): string {
|
||||
// Try Node's crypto synchronously
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const nodeCrypto = (typeof require !== "undefined") ? require("crypto") : null;
|
||||
if (nodeCrypto && typeof nodeCrypto.createHash === "function") {
|
||||
return nodeCrypto.createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore and throw below
|
||||
}
|
||||
throw new Error("Synchronous SHA-256 is not available in this environment. Use sha256Hex (async) instead.");
|
||||
}
|
||||
@@ -238,7 +238,6 @@ const {
|
||||
|
||||
<!-- 插件市场标签页内容 -->
|
||||
<MarketPluginsTab :state="pageState" />
|
||||
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
@@ -108,8 +108,8 @@
|
||||
</v-chip>
|
||||
</div>
|
||||
<div
|
||||
class="platform-qr-chip"
|
||||
v-if="hasQrPayload(item.id)"
|
||||
class="platform-qr-chip"
|
||||
>
|
||||
<v-chip
|
||||
size="small"
|
||||
@@ -118,11 +118,19 @@
|
||||
class="platform-qr-chip-item"
|
||||
@click.stop="openPlatformQrDialog(item.id)"
|
||||
>
|
||||
<v-icon size="small" start>mdi-qrcode</v-icon>
|
||||
<v-icon
|
||||
size="small"
|
||||
start
|
||||
>
|
||||
mdi-qrcode
|
||||
</v-icon>
|
||||
{{ tm('platformQr.show') }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div v-if="getPlatformStat(item.id)?.unified_webhook && item.webhook_uuid" class="webhook-info">
|
||||
<div
|
||||
v-if="getPlatformStat(item.id)?.unified_webhook && item.webhook_uuid"
|
||||
class="webhook-info"
|
||||
>
|
||||
<v-chip
|
||||
size="small"
|
||||
color="primary"
|
||||
@@ -242,10 +250,15 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="showQrDialog" max-width="480">
|
||||
<v-dialog
|
||||
v-model="showQrDialog"
|
||||
max-width="480"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center pa-4">
|
||||
<v-icon class="me-2">mdi-qrcode</v-icon>
|
||||
<v-icon class="me-2">
|
||||
mdi-qrcode
|
||||
</v-icon>
|
||||
{{ tm('platformQr.title') }}
|
||||
</v-card-title>
|
||||
<v-card-text class="px-4 pb-4">
|
||||
@@ -258,8 +271,12 @@
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4 pt-0">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="tonal" color="primary" @click="showQrDialog = false">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click="showQrDialog = false"
|
||||
>
|
||||
{{ tm('platformQr.close') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
@@ -267,10 +284,15 @@
|
||||
</v-dialog>
|
||||
|
||||
<!-- 错误详情对话框 -->
|
||||
<v-dialog v-model="showErrorDialog" max-width="700">
|
||||
<v-dialog
|
||||
v-model="showErrorDialog"
|
||||
max-width="700"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center pa-4">
|
||||
<v-icon class="me-2">mdi-qrcode</v-icon>
|
||||
<v-icon class="me-2">
|
||||
mdi-qrcode
|
||||
</v-icon>
|
||||
{{ tm('platformQr.title') }}
|
||||
</v-card-title>
|
||||
<v-card-text class="px-4 pb-4">
|
||||
@@ -283,8 +305,12 @@
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4 pt-0">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="tonal" color="primary" @click="showQrDialog = false">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click="showQrDialog = false"
|
||||
>
|
||||
{{ tm('platformQr.close') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
+307
-210
@@ -1,229 +1,326 @@
|
||||
<template>
|
||||
<div style="background-color: var(--v-theme-surface, #fff); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 24px;">
|
||||
<v-list lines="two">
|
||||
<v-list-subheader>{{ tm('network.title') }}</v-list-subheader>
|
||||
|
||||
<div style="background-color: var(--v-theme-surface, #fff); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 24px;">
|
||||
<v-list-item>
|
||||
<ProxySelector />
|
||||
</v-list-item>
|
||||
|
||||
<v-list lines="two">
|
||||
<v-list-subheader>{{ tm('network.title') }}</v-list-subheader>
|
||||
<v-list-subheader>{{ tm('sidebar.title') }}</v-list-subheader>
|
||||
|
||||
<v-list-item>
|
||||
<ProxySelector></ProxySelector>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
:subtitle="tm('sidebar.customize.subtitle')"
|
||||
:title="tm('sidebar.customize.title')"
|
||||
>
|
||||
<SidebarCustomizer />
|
||||
</v-list-item>
|
||||
|
||||
<v-list-subheader>{{ tm('sidebar.title') }}</v-list-subheader>
|
||||
<v-list-subheader>{{ tm('theme.title') }}</v-list-subheader>
|
||||
|
||||
<v-list-item :subtitle="tm('sidebar.customize.subtitle')" :title="tm('sidebar.customize.title')">
|
||||
<SidebarCustomizer></SidebarCustomizer>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
:subtitle="tm('theme.subtitle')"
|
||||
:title="tm('theme.customize.title')"
|
||||
>
|
||||
<v-row
|
||||
class="mt-2"
|
||||
dense
|
||||
>
|
||||
<v-col
|
||||
cols="4"
|
||||
sm="2"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="primaryColor"
|
||||
type="color"
|
||||
:label="tm('theme.customize.primary')"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
style="max-width: 220px;"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="4"
|
||||
sm="2 "
|
||||
>
|
||||
<v-text-field
|
||||
v-model="secondaryColor"
|
||||
type="color"
|
||||
:label="tm('theme.customize.secondary')"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
style="max-width: 220px;"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click="resetThemeColors"
|
||||
>
|
||||
<v-icon class="mr-2">
|
||||
mdi-restore
|
||||
</v-icon>
|
||||
{{ tm('theme.customize.reset') }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-subheader>{{ tm('theme.title') }}</v-list-subheader>
|
||||
<v-list-subheader>{{ tm('system.title') }}</v-list-subheader>
|
||||
|
||||
<v-list-item :subtitle="tm('theme.subtitle')" :title="tm('theme.customize.title')">
|
||||
<v-row class="mt-2" dense>
|
||||
<v-col cols="4" sm="2">
|
||||
<v-text-field
|
||||
v-model="primaryColor"
|
||||
type="color"
|
||||
:label="tm('theme.customize.primary')"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
style="max-width: 220px;"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="4" sm="2 ">
|
||||
<v-text-field
|
||||
v-model="secondaryColor"
|
||||
type="color"
|
||||
:label="tm('theme.customize.secondary')"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
style="max-width: 220px;"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-btn size="small" variant="tonal" color="primary" @click="resetThemeColors">
|
||||
<v-icon class="mr-2">mdi-restore</v-icon>
|
||||
{{ tm('theme.customize.reset') }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
:subtitle="tm('system.backup.subtitle')"
|
||||
:title="tm('system.backup.title')"
|
||||
>
|
||||
<v-btn
|
||||
style="margin-top: 16px;"
|
||||
color="primary"
|
||||
@click="openBackupDialog"
|
||||
>
|
||||
<v-icon class="mr-2">
|
||||
mdi-backup-restore
|
||||
</v-icon>
|
||||
{{ tm('system.backup.button') }}
|
||||
</v-btn>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-subheader>{{ tm('system.title') }}</v-list-subheader>
|
||||
<v-list-item
|
||||
:subtitle="tm('system.restart.subtitle')"
|
||||
:title="tm('system.restart.title')"
|
||||
>
|
||||
<v-btn
|
||||
style="margin-top: 16px;"
|
||||
color="error"
|
||||
@click="restartAstrBot"
|
||||
>
|
||||
{{ tm('system.restart.button') }}
|
||||
</v-btn>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item :subtitle="tm('system.backup.subtitle')" :title="tm('system.backup.title')">
|
||||
<v-btn style="margin-top: 16px;" color="primary" @click="openBackupDialog">
|
||||
<v-icon class="mr-2">mdi-backup-restore</v-icon>
|
||||
{{ tm('system.backup.button') }}
|
||||
<v-list-item class="py-2">
|
||||
<StorageCleanupPanel />
|
||||
</v-list-item>
|
||||
|
||||
<v-list-subheader>{{ tm('apiKey.title') }}</v-list-subheader>
|
||||
|
||||
<v-list-item :subtitle="tm('apiKey.subtitle')">
|
||||
<template #title>
|
||||
<div class="d-flex align-center">
|
||||
<span>{{ tm('apiKey.manageTitle') }}</span>
|
||||
<v-tooltip location="top">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
:aria-label="tm('apiKey.docsLink')"
|
||||
href="https://docs.astrbot.app/dev/openapi.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<v-icon size="18">
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<span>{{ tm('apiKey.docsLink') }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<v-row
|
||||
class="mt-2"
|
||||
dense
|
||||
>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="newApiKeyName"
|
||||
:label="tm('apiKey.name')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="3"
|
||||
>
|
||||
<v-select
|
||||
v-model="newApiKeyExpiresInDays"
|
||||
:items="apiKeyExpiryOptions"
|
||||
:label="tm('apiKey.expiresInDays')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="newApiKeyExpiresInDays === 'permanent'"
|
||||
cols="12"
|
||||
>
|
||||
<v-alert
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
>
|
||||
{{ tm('apiKey.permanentWarning') }}
|
||||
</v-alert>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="5"
|
||||
class="d-flex align-center"
|
||||
>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="apiKeyCreating"
|
||||
@click="createApiKey"
|
||||
>
|
||||
<v-icon class="mr-2">
|
||||
mdi-key-plus
|
||||
</v-icon>
|
||||
{{ tm('apiKey.create') }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-list-item :subtitle="tm('system.restart.subtitle')" :title="tm('system.restart.title')">
|
||||
<v-btn style="margin-top: 16px;" color="error" @click="restartAstrBot">{{ tm('system.restart.button') }}</v-btn>
|
||||
</v-list-item>
|
||||
<v-col cols="12">
|
||||
<div class="text-caption text-medium-emphasis mb-1">
|
||||
{{ tm('apiKey.scopes') }}
|
||||
</div>
|
||||
<v-chip-group
|
||||
v-model="newApiKeyScopes"
|
||||
multiple
|
||||
>
|
||||
<v-chip
|
||||
v-for="scope in availableScopes"
|
||||
:key="scope.value"
|
||||
:value="scope.value"
|
||||
:color="newApiKeyScopes.includes(scope.value) ? 'primary' : undefined"
|
||||
:variant="newApiKeyScopes.includes(scope.value) ? 'flat' : 'tonal'"
|
||||
>
|
||||
{{ scope.label }}
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
</v-col>
|
||||
|
||||
<v-list-item class="py-2">
|
||||
<StorageCleanupPanel />
|
||||
</v-list-item>
|
||||
<v-col
|
||||
v-if="createdApiKeyPlaintext"
|
||||
cols="12"
|
||||
>
|
||||
<v-alert
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
>
|
||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||
<span>{{ tm('apiKey.plaintextHint') }}</span>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="copyCreatedApiKey"
|
||||
>
|
||||
<v-icon class="mr-1">
|
||||
mdi-content-copy
|
||||
</v-icon>{{ tm('apiKey.copy') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
<code style="word-break: break-all;">{{ createdApiKeyPlaintext }}</code>
|
||||
</v-alert>
|
||||
</v-col>
|
||||
|
||||
<v-list-subheader>{{ tm('apiKey.title') }}</v-list-subheader>
|
||||
<v-col cols="12">
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ tm('apiKey.table.name') }}</th>
|
||||
<th>{{ tm('apiKey.table.prefix') }}</th>
|
||||
<th>{{ tm('apiKey.table.scopes') }}</th>
|
||||
<th>{{ tm('apiKey.table.status') }}</th>
|
||||
<th>{{ tm('apiKey.table.lastUsed') }}</th>
|
||||
<th>{{ tm('apiKey.table.createdAt') }}</th>
|
||||
<th>{{ tm('apiKey.table.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in apiKeys"
|
||||
:key="item.key_id"
|
||||
>
|
||||
<td>{{ item.name }}</td>
|
||||
<td><code>{{ item.key_prefix }}</code></td>
|
||||
<td>{{ (item.scopes || []).join(', ') }}</td>
|
||||
<td>
|
||||
<v-chip
|
||||
size="small"
|
||||
:color="item.is_revoked || item.is_expired ? 'error' : 'success'"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ item.is_revoked || item.is_expired ? tm('apiKey.status.inactive') : tm('apiKey.status.active') }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td>{{ formatDate(item.last_used_at) }}</td>
|
||||
<td>{{ formatDate(item.created_at) }}</td>
|
||||
<td>
|
||||
<v-btn
|
||||
v-if="!item.is_revoked"
|
||||
size="x-small"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
class="mr-2"
|
||||
@click="revokeApiKey(item.key_id)"
|
||||
>
|
||||
{{ tm('apiKey.revoke') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
@click="deleteApiKey(item.key_id)"
|
||||
>
|
||||
{{ tm('apiKey.delete') }}
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="apiKeys.length === 0">
|
||||
<td
|
||||
colspan="7"
|
||||
class="text-center text-medium-emphasis"
|
||||
>
|
||||
{{ tm('apiKey.empty') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-list-item :subtitle="tm('apiKey.subtitle')">
|
||||
<template #title>
|
||||
<div class="d-flex align-center">
|
||||
<span>{{ tm('apiKey.manageTitle') }}</span>
|
||||
<v-tooltip location="top">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
:aria-label="tm('apiKey.docsLink')"
|
||||
href="https://docs.astrbot.app/dev/openapi.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<v-icon size="18">mdi-help-circle-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>{{ tm('apiKey.docsLink') }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<v-row class="mt-2" dense>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
v-model="newApiKeyName"
|
||||
:label="tm('apiKey.name')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="newApiKeyExpiresInDays"
|
||||
:items="apiKeyExpiryOptions"
|
||||
:label="tm('apiKey.expiresInDays')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col v-if="newApiKeyExpiresInDays === 'permanent'" cols="12">
|
||||
<v-alert type="warning" variant="tonal" density="comfortable">
|
||||
{{ tm('apiKey.permanentWarning') }}
|
||||
</v-alert>
|
||||
</v-col>
|
||||
<v-col cols="12" md="5" class="d-flex align-center">
|
||||
<v-btn color="primary" :loading="apiKeyCreating" @click="createApiKey">
|
||||
<v-icon class="mr-2">mdi-key-plus</v-icon>
|
||||
{{ tm('apiKey.create') }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<div class="text-caption text-medium-emphasis mb-1">{{ tm('apiKey.scopes') }}</div>
|
||||
<v-chip-group v-model="newApiKeyScopes" multiple>
|
||||
<v-chip
|
||||
v-for="scope in availableScopes"
|
||||
:key="scope.value"
|
||||
:value="scope.value"
|
||||
:color="newApiKeyScopes.includes(scope.value) ? 'primary' : undefined"
|
||||
:variant="newApiKeyScopes.includes(scope.value) ? 'flat' : 'tonal'"
|
||||
>
|
||||
{{ scope.label }}
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
</v-col>
|
||||
|
||||
<v-col v-if="createdApiKeyPlaintext" cols="12">
|
||||
<v-alert type="warning" variant="tonal">
|
||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||
<span>{{ tm('apiKey.plaintextHint') }}</span>
|
||||
<v-btn size="small" variant="text" color="primary" @click="copyCreatedApiKey">
|
||||
<v-icon class="mr-1">mdi-content-copy</v-icon>{{ tm('apiKey.copy') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
<code style="word-break: break-all;">{{ createdApiKeyPlaintext }}</code>
|
||||
</v-alert>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ tm('apiKey.table.name') }}</th>
|
||||
<th>{{ tm('apiKey.table.prefix') }}</th>
|
||||
<th>{{ tm('apiKey.table.scopes') }}</th>
|
||||
<th>{{ tm('apiKey.table.status') }}</th>
|
||||
<th>{{ tm('apiKey.table.lastUsed') }}</th>
|
||||
<th>{{ tm('apiKey.table.createdAt') }}</th>
|
||||
<th>{{ tm('apiKey.table.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in apiKeys" :key="item.key_id">
|
||||
<td>{{ item.name }}</td>
|
||||
<td><code>{{ item.key_prefix }}</code></td>
|
||||
<td>{{ (item.scopes || []).join(', ') }}</td>
|
||||
<td>
|
||||
<v-chip
|
||||
size="small"
|
||||
:color="item.is_revoked || item.is_expired ? 'error' : 'success'"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ item.is_revoked || item.is_expired ? tm('apiKey.status.inactive') : tm('apiKey.status.active') }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td>{{ formatDate(item.last_used_at) }}</td>
|
||||
<td>{{ formatDate(item.created_at) }}</td>
|
||||
<td>
|
||||
<v-btn
|
||||
v-if="!item.is_revoked"
|
||||
size="x-small"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
class="mr-2"
|
||||
@click="revokeApiKey(item.key_id)"
|
||||
>
|
||||
{{ tm('apiKey.revoke') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
@click="deleteApiKey(item.key_id)"
|
||||
>
|
||||
{{ tm('apiKey.delete') }}
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="apiKeys.length === 0">
|
||||
<td colspan="7" class="text-center text-medium-emphasis">
|
||||
{{ tm('apiKey.empty') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-list-item :subtitle="tm('system.migration.subtitle')" :title="tm('system.migration.title')">
|
||||
<v-btn style="margin-top: 16px;" color="primary" @click="startMigration">{{ tm('system.migration.button') }}</v-btn>
|
||||
</v-list-item>
|
||||
|
||||
</div>
|
||||
|
||||
<WaitingForRestart ref="wfr"></WaitingForRestart>
|
||||
<MigrationDialog ref="migrationDialog"></MigrationDialog>
|
||||
<BackupDialog ref="backupDialog"></BackupDialog>
|
||||
<v-list-item
|
||||
:subtitle="tm('system.migration.subtitle')"
|
||||
:title="tm('system.migration.title')"
|
||||
>
|
||||
<v-btn
|
||||
style="margin-top: 16px;"
|
||||
color="primary"
|
||||
@click="startMigration"
|
||||
>
|
||||
{{ tm('system.migration.button') }}
|
||||
</v-btn>
|
||||
</v-list-item>
|
||||
</div>
|
||||
|
||||
<WaitingForRestart ref="wfr" />
|
||||
<MigrationDialog ref="migrationDialog" />
|
||||
<BackupDialog ref="backupDialog" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div class="welcome-page">
|
||||
<v-container fluid class="pa-0">
|
||||
<v-container
|
||||
fluid
|
||||
class="pa-0"
|
||||
>
|
||||
<v-row class="px-4 py-3 pb-6">
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h1 font-weight-bold mb-2 d-flex align-center">
|
||||
@@ -14,7 +17,11 @@
|
||||
|
||||
<v-row class="px-4">
|
||||
<v-col cols="12">
|
||||
<v-card class="welcome-card pa-6" elevation="0" border>
|
||||
<v-card
|
||||
class="welcome-card pa-6"
|
||||
elevation="0"
|
||||
border
|
||||
>
|
||||
<div class="mb-4 text-h3 font-weight-bold">
|
||||
{{ tm("onboard.title") }}
|
||||
</div>
|
||||
@@ -45,7 +52,7 @@
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">
|
||||
{{
|
||||
tm("onboard.step0Desc") ||
|
||||
"配置 AstrBot 的后端 API 地址。"
|
||||
"配置 AstrBot 的后端 API 地址。"
|
||||
}}
|
||||
</p>
|
||||
<div class="d-flex align-center">
|
||||
@@ -178,12 +185,19 @@
|
||||
|
||||
<v-row class="px-4 mt-4">
|
||||
<v-col cols="12">
|
||||
<v-card class="welcome-card pa-6" elevation="0" border>
|
||||
<v-card
|
||||
class="welcome-card pa-6"
|
||||
elevation="0"
|
||||
border
|
||||
>
|
||||
<div class="mb-4 text-h3 font-weight-bold">
|
||||
{{ tm("resources.title") }}
|
||||
</div>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<!-- GitHub Card -->
|
||||
<v-card
|
||||
variant="outlined"
|
||||
@@ -192,7 +206,12 @@
|
||||
target="_blank"
|
||||
>
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon size="32" class="mr-3"> mdi-github </v-icon>
|
||||
<v-icon
|
||||
size="32"
|
||||
class="mr-3"
|
||||
>
|
||||
mdi-github
|
||||
</v-icon>
|
||||
<span class="text-h6 font-weight-bold">GitHub</span>
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||
@@ -201,7 +220,10 @@
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="4">
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<!-- Docs Card -->
|
||||
<v-card
|
||||
variant="outlined"
|
||||
@@ -210,7 +232,10 @@
|
||||
target="_blank"
|
||||
>
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon size="32" class="mr-3">
|
||||
<v-icon
|
||||
size="32"
|
||||
class="mr-3"
|
||||
>
|
||||
mdi-book-open-variant
|
||||
</v-icon>
|
||||
<span class="text-h6 font-weight-bold">{{
|
||||
@@ -223,7 +248,10 @@
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="4">
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<!-- Afdian Card -->
|
||||
<v-card
|
||||
variant="outlined"
|
||||
@@ -232,7 +260,12 @@
|
||||
target="_blank"
|
||||
>
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon size="32" class="mr-3"> mdi-hand-heart </v-icon>
|
||||
<v-icon
|
||||
size="32"
|
||||
class="mr-3"
|
||||
>
|
||||
mdi-hand-heart
|
||||
</v-icon>
|
||||
<span class="text-h6 font-weight-bold">{{
|
||||
tm("resources.afdianTitle")
|
||||
}}</span>
|
||||
@@ -247,9 +280,16 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="showAnnouncement" class="px-4 mb-4">
|
||||
<v-row
|
||||
v-if="showAnnouncement"
|
||||
class="px-4 mb-4"
|
||||
>
|
||||
<v-col cols="12">
|
||||
<v-card class="welcome-card pa-6" elevation="0" border>
|
||||
<v-card
|
||||
class="welcome-card pa-6"
|
||||
elevation="0"
|
||||
border
|
||||
>
|
||||
<div class="mb-4 text-h3 font-weight-bold">
|
||||
{{ tm("announcement.title") }}
|
||||
</div>
|
||||
|
||||
@@ -73,14 +73,17 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="login-page-container">
|
||||
<v-card class="login-card" elevation="1">
|
||||
<v-card
|
||||
class="login-card"
|
||||
elevation="1"
|
||||
>
|
||||
<v-card-title>
|
||||
<div class="d-flex justify-space-between align-center w-100">
|
||||
<img
|
||||
width="80"
|
||||
src="@/assets/images/icon-no-shadow.svg"
|
||||
alt="AstrBot Logo"
|
||||
/>
|
||||
>
|
||||
<div class="d-flex align-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
<v-divider
|
||||
@@ -100,10 +103,16 @@ onMounted(() => {
|
||||
size="small"
|
||||
@click="serverConfigDialog = true"
|
||||
>
|
||||
<v-icon size="18" :color="'rgb(var(--v-theme-primary))'">
|
||||
<v-icon
|
||||
size="18"
|
||||
:color="'rgb(var(--v-theme-primary))'"
|
||||
>
|
||||
mdi-server
|
||||
</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
<v-tooltip
|
||||
activator="parent"
|
||||
location="top"
|
||||
>
|
||||
{{ t("serverConfig.tooltip") }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
@@ -115,14 +124,20 @@ onMounted(() => {
|
||||
size="small"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<v-icon size="18" :color="'rgb(var(--v-theme-primary))'">
|
||||
<v-icon
|
||||
size="18"
|
||||
:color="'rgb(var(--v-theme-primary))'"
|
||||
>
|
||||
{{
|
||||
customizer.isDarkTheme
|
||||
? "mdi-weather-night"
|
||||
: "mdi-white-balance-sunny"
|
||||
}}
|
||||
</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
<v-tooltip
|
||||
activator="parent"
|
||||
location="top"
|
||||
>
|
||||
{{
|
||||
customizer.isDarkTheme
|
||||
? t("theme.switchToLight")
|
||||
@@ -132,10 +147,16 @@ onMounted(() => {
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2" style="font-size: 26px">
|
||||
<div
|
||||
class="ml-2"
|
||||
style="font-size: 26px"
|
||||
>
|
||||
{{ t("logo.title") }}
|
||||
</div>
|
||||
<div class="mt-2 ml-2" style="font-size: 14px; color: grey">
|
||||
<div
|
||||
class="mt-2 ml-2"
|
||||
style="font-size: 14px; color: grey"
|
||||
>
|
||||
{{ t("logo.subtitle") }}
|
||||
</div>
|
||||
</v-card-title>
|
||||
@@ -144,7 +165,10 @@ onMounted(() => {
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-dialog v-model="serverConfigDialog" max-width="450">
|
||||
<v-dialog
|
||||
v-model="serverConfigDialog"
|
||||
max-width="450"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title>{{ t("serverConfig.title") }}</v-card-title>
|
||||
<v-card-text>
|
||||
@@ -155,7 +179,7 @@ onMounted(() => {
|
||||
<div
|
||||
v-if="
|
||||
(apiStore.presets && apiStore.presets.length > 0) ||
|
||||
apiStore.customPresets
|
||||
apiStore.customPresets
|
||||
"
|
||||
class="mb-4"
|
||||
>
|
||||
@@ -236,10 +260,17 @@ onMounted(() => {
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="serverConfigDialog = false">
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="serverConfigDialog = false"
|
||||
>
|
||||
{{ t("serverConfig.cancel") }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="saveApiUrl">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="saveApiUrl"
|
||||
>
|
||||
{{ t("serverConfig.save") }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
@@ -80,7 +80,10 @@ async function validate(_values: any, { setErrors }: any) {
|
||||
<span class="login-btn-text">{{ t("login") }}</span>
|
||||
</v-btn>
|
||||
|
||||
<div v-if="errors.apiError" class="mt-4 error-container">
|
||||
<div
|
||||
v-if="errors.apiError"
|
||||
class="mt-4 error-container"
|
||||
>
|
||||
<v-alert
|
||||
color="error"
|
||||
variant="tonal"
|
||||
|
||||
@@ -310,119 +310,11 @@ const pinnedPlugins = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<div class="installed-toolbar">
|
||||
<div class="installed-toolbar__actions">
|
||||
<v-btn variant="tonal" @click="toggleShowReserved">
|
||||
<v-icon>{{
|
||||
showReserved ? "mdi-eye-off" : "mdi-eye"
|
||||
}}</v-icon>
|
||||
{{
|
||||
showReserved
|
||||
? tm("buttons.hideSystemPlugins")
|
||||
: tm("buttons.showSystemPlugins")
|
||||
}}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
:disabled="updatableExtensions.length === 0"
|
||||
:loading="updatingAll"
|
||||
@click="showUpdateAllConfirm"
|
||||
>
|
||||
<v-icon>mdi-update</v-icon>
|
||||
{{ tm("buttons.updateAll") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="installed-toolbar__controls">
|
||||
<v-btn-toggle
|
||||
v-model="installedStatusFilter"
|
||||
mandatory
|
||||
divided
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="installed-status-toggle"
|
||||
>
|
||||
<v-btn value="all" prepend-icon="mdi-filter-variant">
|
||||
{{ tm("filters.all") }}
|
||||
</v-btn>
|
||||
<v-btn value="enabled" prepend-icon="mdi-play-circle-outline">
|
||||
{{ tm("status.enabled") }}
|
||||
</v-btn>
|
||||
<v-btn value="disabled" prepend-icon="mdi-pause-circle-outline">
|
||||
{{ tm("status.disabled") }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<PluginSortControl
|
||||
v-model="installedSortBy"
|
||||
:items="installedSortItems"
|
||||
:label="tm('sort.by')"
|
||||
:order="installedSortOrder"
|
||||
:ascending-label="tm('sort.ascending')"
|
||||
:descending-label="tm('sort.descending')"
|
||||
:show-order="installedSortUsesOrder"
|
||||
@update:order="installedSortOrder = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 置顶插件列表 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<v-card class="rounded-lg overflow-hidden elevation-0" variant="flat">
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<h3 class="text-h6 mb-0">{{ tm('titles.pinnedPlugins') }}</h3>
|
||||
</div>
|
||||
|
||||
<v-row class="mt-3 relative" dense align="center" style="gap:12px">
|
||||
<template v-if="!pinnedPlugins || pinnedPlugins.length === 0">
|
||||
<v-col cols="auto" v-for="n in 4" :key="n">
|
||||
</v-col>
|
||||
</template>
|
||||
|
||||
<transition-group name="list" class="v-row v-row--dense">
|
||||
<v-col
|
||||
cols="auto"
|
||||
v-for="(p, index) in pinnedPlugins"
|
||||
:key="p.name"
|
||||
>
|
||||
<PinnedPluginItem
|
||||
:plugin="p"
|
||||
:is-pinned="isPinned(p.name)"
|
||||
:tm="tm"
|
||||
:dragged="draggedIndex === index"
|
||||
@toggle-pin="togglePin"
|
||||
@view-readme="viewReadme"
|
||||
@open-config="openExtensionConfig"
|
||||
@reload="reloadPlugin"
|
||||
@update="updateExtension"
|
||||
@show-info="showPluginInfo"
|
||||
@uninstall="uninstallExtension"
|
||||
@dragstart="onDragStart(index)"
|
||||
@dragover="onDragOver($event)"
|
||||
@dragenter="onDragEnter($event, index)"
|
||||
@dragend="onDragEnd($event)"
|
||||
@drop="onDrop($event)"
|
||||
/>
|
||||
</v-col>
|
||||
</transition-group>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
|
||||
<v-card
|
||||
v-if="failedPluginItems.length > 0"
|
||||
class="mb-4 rounded-lg"
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<div class="installed-toolbar">
|
||||
<div class="installed-toolbar__actions">
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
@click="toggleShowReserved"
|
||||
>
|
||||
@@ -494,160 +386,316 @@ const pinnedPlugins = computed(() => {
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 置顶插件列表 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<v-card
|
||||
class="rounded-lg overflow-hidden elevation-0"
|
||||
variant="flat"
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<h3 class="text-h6 mb-0">
|
||||
{{ tm('titles.pinnedPlugins') }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<v-row
|
||||
class="mt-3 relative"
|
||||
dense
|
||||
align="center"
|
||||
style="gap:12px"
|
||||
>
|
||||
<template v-if="!pinnedPlugins || pinnedPlugins.length === 0">
|
||||
<v-col
|
||||
v-for="n in 4"
|
||||
:key="n"
|
||||
cols="auto"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<transition-group
|
||||
name="list"
|
||||
class="v-row v-row--dense"
|
||||
>
|
||||
<v-col
|
||||
v-for="(p, index) in pinnedPlugins"
|
||||
:key="p.name"
|
||||
cols="auto"
|
||||
>
|
||||
<PinnedPluginItem
|
||||
:plugin="p"
|
||||
:is-pinned="isPinned(p.name)"
|
||||
:tm="tm"
|
||||
:dragged="draggedIndex === index"
|
||||
@toggle-pin="togglePin"
|
||||
@view-readme="viewReadme"
|
||||
@open-config="openExtensionConfig"
|
||||
@reload="reloadPlugin"
|
||||
@update="updateExtension"
|
||||
@show-info="showPluginInfo"
|
||||
@uninstall="uninstallExtension"
|
||||
@dragstart="onDragStart(index)"
|
||||
@dragover="onDragOver($event)"
|
||||
@dragenter="onDragEnter($event, index)"
|
||||
@dragend="onDragEnd($event)"
|
||||
@drop="onDrop($event)"
|
||||
/>
|
||||
</v-col>
|
||||
</transition-group>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
|
||||
<v-card
|
||||
v-if="failedPluginItems.length > 0"
|
||||
class="mb-4 rounded-lg"
|
||||
variant="tonal"
|
||||
color="warning"
|
||||
@click="toggleShowReserved"
|
||||
>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon
|
||||
color="warning"
|
||||
class="mr-2"
|
||||
<v-icon>
|
||||
{{
|
||||
showReserved ? "mdi-eye-off" : "mdi-eye"
|
||||
}}
|
||||
</v-icon>
|
||||
{{
|
||||
showReserved
|
||||
? tm("buttons.hideSystemPlugins")
|
||||
: tm("buttons.showSystemPlugins")
|
||||
}}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
:disabled="updatableExtensions.length === 0"
|
||||
:loading="updatingAll"
|
||||
@click="showUpdateAllConfirm"
|
||||
>
|
||||
<v-icon>mdi-update</v-icon>
|
||||
{{ tm("buttons.updateAll") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="installed-toolbar__controls">
|
||||
<v-btn-toggle
|
||||
v-model="installedStatusFilter"
|
||||
mandatory
|
||||
divided
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="installed-status-toggle"
|
||||
>
|
||||
mdi-alert-circle
|
||||
</v-icon>
|
||||
{{ tm("failedPlugins.title", { count: failedPluginItems.length }) }}
|
||||
</v-card-title>
|
||||
<v-card-text class="pt-0">
|
||||
<div class="text-body-2 mb-3">
|
||||
{{ tm("failedPlugins.hint") }}
|
||||
</div>
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ tm("failedPlugins.columns.plugin") }}</th>
|
||||
<th>{{ tm("failedPlugins.columns.error") }}</th>
|
||||
<th class="text-right">
|
||||
{{ tm("buttons.actions") }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="plugin in failedPluginItems"
|
||||
:key="plugin.dir_name"
|
||||
>
|
||||
<td>
|
||||
<div class="font-weight-medium">
|
||||
{{ plugin.display_name }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ plugin.dir_name }}
|
||||
</div>
|
||||
</td>
|
||||
<td style="max-width: 520px">
|
||||
<div
|
||||
class="text-caption text-medium-emphasis"
|
||||
style="
|
||||
<v-btn
|
||||
value="all"
|
||||
prepend-icon="mdi-filter-variant"
|
||||
>
|
||||
{{ tm("filters.all") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
value="enabled"
|
||||
prepend-icon="mdi-play-circle-outline"
|
||||
>
|
||||
{{ tm("status.enabled") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
value="disabled"
|
||||
prepend-icon="mdi-pause-circle-outline"
|
||||
>
|
||||
{{ tm("status.disabled") }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<PluginSortControl
|
||||
v-model="installedSortBy"
|
||||
:items="installedSortItems"
|
||||
:label="tm('sort.by')"
|
||||
:order="installedSortOrder"
|
||||
:ascending-label="tm('sort.ascending')"
|
||||
:descending-label="tm('sort.descending')"
|
||||
:show-order="installedSortUsesOrder"
|
||||
@update:order="installedSortOrder = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-card
|
||||
v-if="failedPluginItems.length > 0"
|
||||
class="mb-4 rounded-lg"
|
||||
variant="tonal"
|
||||
color="warning"
|
||||
>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon
|
||||
color="warning"
|
||||
class="mr-2"
|
||||
>
|
||||
mdi-alert-circle
|
||||
</v-icon>
|
||||
{{ tm("failedPlugins.title", { count: failedPluginItems.length }) }}
|
||||
</v-card-title>
|
||||
<v-card-text class="pt-0">
|
||||
<div class="text-body-2 mb-3">
|
||||
{{ tm("failedPlugins.hint") }}
|
||||
</div>
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ tm("failedPlugins.columns.plugin") }}</th>
|
||||
<th>{{ tm("failedPlugins.columns.error") }}</th>
|
||||
<th class="text-right">
|
||||
{{ tm("buttons.actions") }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="plugin in failedPluginItems"
|
||||
:key="plugin.dir_name"
|
||||
>
|
||||
<td>
|
||||
<div class="font-weight-medium">
|
||||
{{ plugin.display_name }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ plugin.dir_name }}
|
||||
</div>
|
||||
</td>
|
||||
<td style="max-width: 520px">
|
||||
<div
|
||||
class="text-caption text-medium-emphasis"
|
||||
style="
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
"
|
||||
>
|
||||
{{ plugin.error || tm("status.unknown") }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="mr-2"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="reloadFailedPlugin(plugin.dir_name)"
|
||||
>
|
||||
{{ tm("buttons.reload") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="error"
|
||||
prepend-icon="mdi-delete"
|
||||
:disabled="plugin.reserved"
|
||||
@click="requestUninstallFailedPlugin(plugin.dir_name)"
|
||||
>
|
||||
{{ tm("buttons.uninstall") }}
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
>
|
||||
{{ plugin.error || tm("status.unknown") }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="mr-2"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="reloadFailedPlugin(plugin.dir_name)"
|
||||
>
|
||||
{{ tm("buttons.reload") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="error"
|
||||
prepend-icon="mdi-delete"
|
||||
:disabled="plugin.reserved"
|
||||
@click="requestUninstallFailedPlugin(plugin.dir_name)"
|
||||
>
|
||||
{{ tm("buttons.uninstall") }}
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-fade-transition hide-on-leave>
|
||||
<!-- 表格视图 -->
|
||||
<div v-if="isListView">
|
||||
<v-card class="rounded-lg overflow-hidden elevation-0">
|
||||
<v-data-table
|
||||
class="plugin-list-table"
|
||||
:headers="pluginHeaders"
|
||||
:items="filteredPlugins"
|
||||
:loading="loading_"
|
||||
item-key="name"
|
||||
hover
|
||||
>
|
||||
<template #loader>
|
||||
<v-row class="py-8 d-flex align-center justify-center">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
<span class="ml-2">{{ tm("status.loading") }}</span>
|
||||
</v-row>
|
||||
</template>
|
||||
<v-fade-transition hide-on-leave>
|
||||
<!-- 表格视图 -->
|
||||
<div v-if="isListView">
|
||||
<v-card class="rounded-lg overflow-hidden elevation-0">
|
||||
<v-data-table
|
||||
class="plugin-list-table"
|
||||
:headers="pluginHeaders"
|
||||
:items="filteredPlugins"
|
||||
:loading="loading_"
|
||||
item-key="name"
|
||||
hover
|
||||
>
|
||||
<template #loader>
|
||||
<v-row class="py-8 d-flex align-center justify-center">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
<span class="ml-2">{{ tm("status.loading") }}</span>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<template #item.name="{ item }">
|
||||
<div class="d-flex align-center py-2">
|
||||
<div
|
||||
v-if="item.logo"
|
||||
class="mr-3"
|
||||
style="flex-shrink: 0"
|
||||
>
|
||||
<img
|
||||
:src="item.logo"
|
||||
:alt="item.name"
|
||||
style="
|
||||
<template #item.name="{ item }">
|
||||
<div class="d-flex align-center py-2">
|
||||
<div
|
||||
v-if="item.logo"
|
||||
class="mr-3"
|
||||
style="flex-shrink: 0"
|
||||
>
|
||||
<img
|
||||
:src="item.logo"
|
||||
:alt="item.name"
|
||||
style="
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
"
|
||||
>
|
||||
<template v-slot:item.name="{ item }">
|
||||
>
|
||||
<template #item.name="{ item }">
|
||||
<div class="d-flex">
|
||||
<div class="mr-3" style="flex-shrink: 0">
|
||||
<div
|
||||
class="mr-3"
|
||||
style="flex-shrink: 0"
|
||||
>
|
||||
<img
|
||||
:src="(typeof item.logo === 'string' && item.logo.trim()) ? item.logo : defaultPluginIcon"
|
||||
:alt="item.name"
|
||||
style="height: 40px; width: 40px; border-radius: 8px; object-fit: cover;"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-h5" style="font-family: inherit;">
|
||||
<div
|
||||
class="text-h5"
|
||||
style="font-family: inherit;"
|
||||
>
|
||||
{{ item.display_name && item.display_name.length ? item.display_name : item.name }}
|
||||
</div>
|
||||
|
||||
<div v-if="item.display_name && item.display_name.length" class="text-caption text-medium-emphasis mt-1">
|
||||
<div
|
||||
v-if="item.display_name && item.display_name.length"
|
||||
class="text-caption text-medium-emphasis mt-1"
|
||||
>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
|
||||
<div v-if="item.reserved" class="d-flex align-center mt-1">
|
||||
<v-chip color="primary" size="x-small" class="font-weight-medium">{{ tm("status.system") }}</v-chip>
|
||||
<div
|
||||
v-if="item.reserved"
|
||||
class="d-flex align-center mt-1"
|
||||
>
|
||||
<v-chip
|
||||
color="primary"
|
||||
size="x-small"
|
||||
class="font-weight-medium"
|
||||
>
|
||||
{{ tm("status.system") }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.desc="{ item }">
|
||||
<div class="py-2">
|
||||
<div
|
||||
class="text-body-2 text-medium-emphasis"
|
||||
style="
|
||||
<template #item.desc="{ item }">
|
||||
<div class="py-2">
|
||||
<div
|
||||
class="text-body-2 text-medium-emphasis"
|
||||
style="
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
@@ -695,11 +743,14 @@ const pinnedPlugins = computed(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.version="{ item }">
|
||||
<template #item.version="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<span class="text-body-2">{{ item.version }}</span>
|
||||
<v-tooltip v-if="item.has_update" location="top">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-tooltip
|
||||
v-if="item.has_update"
|
||||
location="top"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="warning"
|
||||
@@ -707,16 +758,18 @@ const pinnedPlugins = computed(() => {
|
||||
class="ml-1"
|
||||
style="cursor: pointer"
|
||||
@click.stop="updateExtension(item.name)"
|
||||
>mdi-alert</v-icon
|
||||
>
|
||||
mdi-alert
|
||||
</v-icon>
|
||||
</template>
|
||||
<span
|
||||
>{{ tm("messages.hasUpdate") }}
|
||||
{{ item.online_version }}</span
|
||||
>
|
||||
<span>{{ tm("messages.hasUpdate") }}
|
||||
{{ item.online_version }}</span>
|
||||
</v-tooltip>
|
||||
<v-tooltip v-if="item.has_update" location="top">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-tooltip
|
||||
v-if="item.has_update"
|
||||
location="top"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<span
|
||||
v-bind="tooltipProps"
|
||||
class="ml-1 text-caption text-warning"
|
||||
@@ -731,11 +784,13 @@ const pinnedPlugins = computed(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.author="{ item }">
|
||||
<div class="text-body-2">{{ item.author }}</div>
|
||||
<template #item.author="{ item }">
|
||||
<div class="text-body-2">
|
||||
{{ item.author }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<template #item.actions="{ item }">
|
||||
<div class="table-action-row d-flex align-center flex-nowrap justify-start ga-2 py-1">
|
||||
<v-btn
|
||||
icon
|
||||
@@ -743,10 +798,12 @@ const pinnedPlugins = computed(() => {
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
class="table-action-btn pin-action"
|
||||
@click.stop="togglePin(item)"
|
||||
:title="isPinned(item.name) ? tm('buttons.unpin') : tm('buttons.pin')"
|
||||
@click.stop="togglePin(item)"
|
||||
>
|
||||
<v-icon size="18">{{ isPinned(item.name) ? 'mdi-pin' : 'mdi-pin-outline' }}</v-icon>
|
||||
<v-icon size="18">
|
||||
{{ isPinned(item.name) ? 'mdi-pin' : 'mdi-pin-outline' }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
@@ -806,7 +863,10 @@ const pinnedPlugins = computed(() => {
|
||||
{{ tm("buttons.viewDocs") }}
|
||||
</v-btn>
|
||||
|
||||
<StyledMenu location="bottom end" offset="8">
|
||||
<StyledMenu
|
||||
location="bottom end"
|
||||
offset="8"
|
||||
>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
@@ -815,16 +875,16 @@ const pinnedPlugins = computed(() => {
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
class="table-action-btn"
|
||||
></v-btn>
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
prepend-icon="mdi-information"
|
||||
@click="showPluginInfo(item)"
|
||||
>
|
||||
<v-list-item-title>{{ tm("buttons.viewInfo") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
>
|
||||
<v-list-item-title>{{ tm("buttons.viewInfo") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
@@ -846,11 +906,15 @@ const pinnedPlugins = computed(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:no-data>
|
||||
<template #no-data>
|
||||
<div class="text-center pa-8">
|
||||
<v-icon size="64" color="info" class="mb-4"
|
||||
>mdi-puzzle-outline</v-icon
|
||||
<v-icon
|
||||
size="64"
|
||||
color="info"
|
||||
class="mb-4"
|
||||
>
|
||||
mdi-puzzle-outline
|
||||
</v-icon>
|
||||
<div class="text-h5 mb-2">
|
||||
{{ tm("empty.noPlugins") }}
|
||||
</div>
|
||||
@@ -859,59 +923,76 @@ const pinnedPlugins = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- 卡片视图 -->
|
||||
<div v-else>
|
||||
<v-row v-if="filteredPlugins.length === 0" class="text-center">
|
||||
<v-col cols="12" class="pa-2">
|
||||
<v-icon size="64" color="info" class="mb-4"
|
||||
>mdi-puzzle-outline</v-icon
|
||||
>
|
||||
<div class="text-h5 mb-2">{{ tm("empty.noPlugins") }}</div>
|
||||
<div class="text-body-1 mb-4">
|
||||
{{ tm("empty.noPluginsDesc") }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
v-for="extension in filteredPlugins"
|
||||
:key="extension.name"
|
||||
class="pb-2"
|
||||
>
|
||||
<ExtensionCard
|
||||
:extension="extension"
|
||||
:pinned="isPinned(extension.name)"
|
||||
@toggle-pin="() => togglePin(extension)"
|
||||
class="rounded-lg"
|
||||
style="background-color: rgb(var(--v-theme-mcpCardBg))"
|
||||
@configure="openExtensionConfig(extension.name)"
|
||||
@uninstall="
|
||||
(ext, options) => uninstallExtension(ext.name, options)
|
||||
"
|
||||
@update="updateExtension(extension.name)"
|
||||
@reload="reloadPlugin(extension.name)"
|
||||
@toggle-activation="
|
||||
extension.activated
|
||||
? pluginOff(extension)
|
||||
: pluginOn(extension)
|
||||
"
|
||||
@view-handlers="showPluginInfo(extension)"
|
||||
@view-readme="viewReadme(extension)"
|
||||
@view-changelog="viewChangelog(extension)"
|
||||
>
|
||||
</ExtensionCard>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<!-- 卡片视图 -->
|
||||
<div v-else>
|
||||
<v-row
|
||||
v-if="filteredPlugins.length === 0"
|
||||
class="text-center"
|
||||
>
|
||||
<v-col
|
||||
cols="12"
|
||||
class="pa-2"
|
||||
>
|
||||
<v-icon
|
||||
size="64"
|
||||
color="info"
|
||||
class="mb-4"
|
||||
>
|
||||
mdi-puzzle-outline
|
||||
</v-icon>
|
||||
<div class="text-h5 mb-2">
|
||||
{{ tm("empty.noPlugins") }}
|
||||
</div>
|
||||
</template>
|
||||
<div class="text-body-1 mb-4">
|
||||
{{ tm("empty.noPluginsDesc") }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="extension in filteredPlugins"
|
||||
:key="extension.name"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
class="pb-2"
|
||||
>
|
||||
<ExtensionCard
|
||||
:extension="extension"
|
||||
:pinned="isPinned(extension.name)"
|
||||
class="rounded-lg"
|
||||
style="background-color: rgb(var(--v-theme-mcpCardBg))"
|
||||
@toggle-pin="() => togglePin(extension)"
|
||||
@configure="openExtensionConfig(extension.name)"
|
||||
@uninstall="
|
||||
(ext, options) => uninstallExtension(ext.name, options)
|
||||
"
|
||||
@update="updateExtension(extension.name)"
|
||||
@reload="reloadPlugin(extension.name)"
|
||||
@toggle-activation="
|
||||
extension.activated
|
||||
? pluginOff(extension)
|
||||
: pluginOn(extension)
|
||||
"
|
||||
@view-handlers="showPluginInfo(extension)"
|
||||
@view-readme="viewReadme(extension)"
|
||||
@view-changelog="viewChangelog(extension)"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-fade-transition>
|
||||
</v-card>
|
||||
</v-tab-item>
|
||||
</template>
|
||||
|
||||
<template #item.version="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
|
||||
@@ -304,134 +304,141 @@ const marketCategorySelectItems = computed(() =>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<div class="mt-4">
|
||||
<div
|
||||
class="d-flex align-center mb-2"
|
||||
style="
|
||||
<div class="mt-4">
|
||||
<div
|
||||
class="d-flex align-center mb-2"
|
||||
style="
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<div class="d-flex align-center" style="gap: 6px">
|
||||
<h2>
|
||||
{{ tm("market.allPlugins") }}
|
||||
</h2>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
@click="refreshPluginMarket"
|
||||
:loading="loading_ || refreshingMarket"
|
||||
:disabled="loading_ || refreshingMarket"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
>
|
||||
<div
|
||||
class="d-flex align-center"
|
||||
style="gap: 6px"
|
||||
>
|
||||
<h2>
|
||||
{{ tm("market.allPlugins") }}
|
||||
</h2>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
:loading="loading_ || refreshingMarket"
|
||||
:disabled="loading_ || refreshingMarket"
|
||||
@click="refreshPluginMarket"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="d-flex align-center"
|
||||
style="gap: 8px; flex-wrap: wrap"
|
||||
>
|
||||
<v-select
|
||||
v-if="marketCategoryItems.length > 0"
|
||||
v-model="marketCategoryFilter"
|
||||
:items="marketCategorySelectItems"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:label="tm('market.category')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="market-filter-control"
|
||||
:menu-props="{ openOnHover: true, closeOnContentClick: false }"
|
||||
></v-select>
|
||||
<div
|
||||
class="d-flex align-center"
|
||||
style="gap: 8px; flex-wrap: wrap"
|
||||
>
|
||||
<v-select
|
||||
v-if="marketCategoryItems.length > 0"
|
||||
v-model="marketCategoryFilter"
|
||||
:items="marketCategorySelectItems"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:label="tm('market.category')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="market-filter-control"
|
||||
:menu-props="{ openOnHover: true, closeOnContentClick: false }"
|
||||
/>
|
||||
|
||||
<PluginSortControl
|
||||
v-model="sortBy"
|
||||
:items="marketSortItems"
|
||||
:label="tm('sort.by')"
|
||||
:order="sortOrder"
|
||||
:ascending-label="tm('sort.ascending')"
|
||||
:descending-label="tm('sort.descending')"
|
||||
:show-order="sortBy !== 'default'"
|
||||
@update:order="sortOrder = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<PluginSortControl
|
||||
v-model="sortBy"
|
||||
:items="marketSortItems"
|
||||
:label="tm('sort.by')"
|
||||
:order="sortOrder"
|
||||
:ascending-label="tm('sort.ascending')"
|
||||
:descending-label="tm('sort.descending')"
|
||||
:show-order="sortBy !== 'default'"
|
||||
@update:order="sortOrder = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row style="min-height: 26rem" dense>
|
||||
<v-col
|
||||
v-for="plugin in paginatedPlugins"
|
||||
:key="plugin.name"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
class="pb-2"
|
||||
>
|
||||
<MarketPluginCard
|
||||
:plugin="plugin"
|
||||
:default-plugin-icon="defaultPluginIcon"
|
||||
:show-plugin-full-name="showPluginFullName"
|
||||
@install="handleInstallPlugin"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row
|
||||
style="min-height: 26rem"
|
||||
dense
|
||||
>
|
||||
<v-col
|
||||
v-for="plugin in paginatedPlugins"
|
||||
:key="plugin.name"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
class="pb-2"
|
||||
>
|
||||
<MarketPluginCard
|
||||
:plugin="plugin"
|
||||
:default-plugin-icon="defaultPluginIcon"
|
||||
:show-plugin-full-name="showPluginFullName"
|
||||
@install="handleInstallPlugin"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div
|
||||
class="d-flex justify-center mt-4"
|
||||
v-if="totalPages > 1"
|
||||
>
|
||||
<v-pagination
|
||||
v-model="currentPage"
|
||||
:length="totalPages"
|
||||
:total-visible="7"
|
||||
size="small"
|
||||
></v-pagination>
|
||||
</div>
|
||||
<div
|
||||
v-if="totalPages > 1"
|
||||
class="d-flex justify-center mt-4"
|
||||
>
|
||||
<v-pagination
|
||||
v-model="currentPage"
|
||||
:length="totalPages"
|
||||
:total-visible="7"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-expand-transition>
|
||||
<div v-if="showRandomPlugins">
|
||||
<div
|
||||
class="d-flex align-center mb-2 mt-4"
|
||||
style="justify-content: space-between; flex-wrap: wrap; gap: 8px"
|
||||
>
|
||||
<h2>
|
||||
{{ tm("market.randomPlugins") }}
|
||||
</h2>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-shuffle-variant"
|
||||
:disabled="pluginMarketData.length === 0"
|
||||
@click="refreshRandomPlugins"
|
||||
>
|
||||
{{ tm("buttons.reshuffle") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-expand-transition>
|
||||
<div v-if="showRandomPlugins">
|
||||
<div
|
||||
class="d-flex align-center mb-2 mt-4"
|
||||
style="justify-content: space-between; flex-wrap: wrap; gap: 8px"
|
||||
>
|
||||
<h2>
|
||||
{{ tm("market.randomPlugins") }}
|
||||
</h2>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-shuffle-variant"
|
||||
:disabled="pluginMarketData.length === 0"
|
||||
@click="refreshRandomPlugins"
|
||||
>
|
||||
{{ tm("buttons.reshuffle") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-row class="mb-6" dense>
|
||||
<v-col
|
||||
v-for="plugin in randomPlugins"
|
||||
:key="`random-${plugin.name}`"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
class="pb-2"
|
||||
>
|
||||
<MarketPluginCard
|
||||
:plugin="plugin"
|
||||
:default-plugin-icon="defaultPluginIcon"
|
||||
:show-plugin-full-name="showPluginFullName"
|
||||
@install="handleInstallPlugin"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</div>
|
||||
|
||||
|
||||
</v-tab-item>
|
||||
<v-row
|
||||
class="mb-6"
|
||||
dense
|
||||
>
|
||||
<v-col
|
||||
v-for="plugin in randomPlugins"
|
||||
:key="`random-${plugin.name}`"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
class="pb-2"
|
||||
>
|
||||
<MarketPluginCard
|
||||
:plugin="plugin"
|
||||
:default-plugin-icon="defaultPluginIcon"
|
||||
:show-plugin-full-name="showPluginFullName"
|
||||
@install="handleInstallPlugin"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</div>
|
||||
</v-tab-item>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<BaseFolderBreadcrumb
|
||||
:breadcrumb-path="breadcrumbPath"
|
||||
:current-folder-id="currentFolderId"
|
||||
:root-folder-name="rootName"
|
||||
@navigate="handleClick"
|
||||
:labels="{ rootFolder: tm('folder.rootFolder') }"
|
||||
class="folder-breadcrumb pa-0"
|
||||
/>
|
||||
<BaseFolderBreadcrumb
|
||||
:breadcrumb-path="breadcrumbPath"
|
||||
:current-folder-id="currentFolderId"
|
||||
:root-folder-name="rootName"
|
||||
:labels="{ rootFolder: tm('folder.rootFolder') }"
|
||||
class="folder-breadcrumb pa-0"
|
||||
@navigate="handleClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<BaseFolderCard
|
||||
:folder="folder"
|
||||
:accept-drop-types="['persona']"
|
||||
:labels="{
|
||||
open: tm('folder.contextMenu.open'),
|
||||
rename: tm('folder.contextMenu.rename'),
|
||||
moveTo: tm('folder.contextMenu.moveTo'),
|
||||
delete: tm('folder.contextMenu.delete')
|
||||
}"
|
||||
@click="$emit('click')"
|
||||
@contextmenu.native.prevent="$emit('contextmenu', $event)"
|
||||
@open="$emit('open')"
|
||||
@rename="$emit('rename')"
|
||||
@move="$emit('move')"
|
||||
@delete="$emit('delete')"
|
||||
@item-dropped="onItemDropped"
|
||||
/>
|
||||
<BaseFolderCard
|
||||
:folder="folder"
|
||||
:accept-drop-types="['persona']"
|
||||
:labels="{
|
||||
open: tm('folder.contextMenu.open'),
|
||||
rename: tm('folder.contextMenu.rename'),
|
||||
moveTo: tm('folder.contextMenu.moveTo'),
|
||||
delete: tm('folder.contextMenu.delete')
|
||||
}"
|
||||
@click="$emit('click')"
|
||||
@contextmenu.native.prevent="$emit('contextmenu', $event)"
|
||||
@open="$emit('open')"
|
||||
@rename="$emit('rename')"
|
||||
@move="$emit('move')"
|
||||
@delete="$emit('delete')"
|
||||
@item-dropped="onItemDropped"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
<template>
|
||||
<div class="folder-tree">
|
||||
<BaseFolderTree
|
||||
:folder-tree="folderTree"
|
||||
:current-folder-id="currentFolderId"
|
||||
:expanded-folder-ids="expandedFolderIds"
|
||||
:tree-loading="treeLoading"
|
||||
:accept-drop-types="['persona']"
|
||||
:labels="{
|
||||
searchPlaceholder: tm('folder.searchPlaceholder'),
|
||||
rootFolder: tm('folder.rootFolder'),
|
||||
noFolders: tm('folder.noFolders'),
|
||||
contextMenu: {
|
||||
open: tm('folder.contextMenu.open'),
|
||||
rename: tm('folder.contextMenu.rename'),
|
||||
moveTo: tm('folder.contextMenu.moveTo'),
|
||||
delete: tm('folder.contextMenu.delete')
|
||||
}
|
||||
}"
|
||||
@folder-click="handleFolderClick"
|
||||
@rename-folder="onRenameFolder"
|
||||
@move-folder="$emit('move-folder', $event)"
|
||||
@delete-folder="onDeleteFolder"
|
||||
@item-dropped="onItemDropped"
|
||||
@toggle-expansion="toggleFolderExpansion"
|
||||
@set-expansion="setFolderExpansion"
|
||||
/>
|
||||
<div class="folder-tree">
|
||||
<BaseFolderTree
|
||||
:folder-tree="folderTree"
|
||||
:current-folder-id="currentFolderId"
|
||||
:expanded-folder-ids="expandedFolderIds"
|
||||
:tree-loading="treeLoading"
|
||||
:accept-drop-types="['persona']"
|
||||
:labels="{
|
||||
searchPlaceholder: tm('folder.searchPlaceholder'),
|
||||
rootFolder: tm('folder.rootFolder'),
|
||||
noFolders: tm('folder.noFolders'),
|
||||
contextMenu: {
|
||||
open: tm('folder.contextMenu.open'),
|
||||
rename: tm('folder.contextMenu.rename'),
|
||||
moveTo: tm('folder.contextMenu.moveTo'),
|
||||
delete: tm('folder.contextMenu.delete')
|
||||
}
|
||||
}"
|
||||
@folder-click="handleFolderClick"
|
||||
@rename-folder="onRenameFolder"
|
||||
@move-folder="$emit('move-folder', $event)"
|
||||
@delete-folder="onDeleteFolder"
|
||||
@item-dropped="onItemDropped"
|
||||
@toggle-expansion="toggleFolderExpansion"
|
||||
@set-expansion="setFolderExpansion"
|
||||
/>
|
||||
|
||||
<!-- 重命名对话框 -->
|
||||
<v-dialog
|
||||
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.23.0"
|
||||
version = "4.25.0"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<3.14"
|
||||
@@ -37,12 +37,11 @@ dependencies = [
|
||||
"psutil>=5.8.0,<7.2.0",
|
||||
"py-cord>=2.6.1",
|
||||
"pydantic>=2.12.5",
|
||||
"pydub>=0.25.1",
|
||||
"quart>=0.19.0,<0.20.0",
|
||||
"pyjwt>=2.10.1",
|
||||
"qrcode>=8.2",
|
||||
"python-telegram-bot>=22.6",
|
||||
"qq-botpy>=1.2.1",
|
||||
"quart>=0.20.0",
|
||||
"readability-lxml>=0.8.4.1",
|
||||
"silk-python>=0.2.6",
|
||||
"slack-sdk>=3.35.0",
|
||||
|
||||
Reference in New Issue
Block a user