Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 602ae4eee2 | |||
| 3d82f42311 | |||
| aec2e3bb91 |
@@ -206,33 +206,16 @@ class ConversationCommands:
|
||||
_titles[conv.cid] = title
|
||||
|
||||
"""遍历分页后的对话生成列表显示"""
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
platform_name = message.get_platform_name()
|
||||
for conv in conversations_paged:
|
||||
(
|
||||
persona_id,
|
||||
_,
|
||||
force_applied_persona_id,
|
||||
_,
|
||||
) = await self.context.persona_manager.resolve_selected_persona(
|
||||
umo=message.unified_msg_origin,
|
||||
conversation_persona_id=conv.persona_id,
|
||||
platform_name=platform_name,
|
||||
provider_settings=provider_settings,
|
||||
)
|
||||
if persona_id == "[%None]":
|
||||
persona_name = "无"
|
||||
elif persona_id:
|
||||
persona_name = persona_id
|
||||
else:
|
||||
persona_name = "无"
|
||||
|
||||
if force_applied_persona_id:
|
||||
persona_name = f"{persona_name} (自定义规则)"
|
||||
|
||||
persona_id = conv.persona_id
|
||||
if not persona_id or persona_id == "[%None]":
|
||||
persona = await self.context.persona_manager.get_default_persona_v3(
|
||||
umo=message.unified_msg_origin,
|
||||
)
|
||||
persona_id = persona["name"]
|
||||
title = _titles.get(conv.cid, "新对话")
|
||||
parts.append(
|
||||
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
|
||||
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_id}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
|
||||
)
|
||||
global_index += 1
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import builtins
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot.api import star
|
||||
from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -59,7 +59,12 @@ class PersonaCommands:
|
||||
default_persona = await self.context.persona_manager.get_default_persona_v3(
|
||||
umo=umo,
|
||||
)
|
||||
force_applied_persona_id = None
|
||||
|
||||
force_applied_persona_id = (
|
||||
await sp.get_async(
|
||||
scope="umo", scope_id=umo, key="session_service_config", default={}
|
||||
)
|
||||
).get("persona_id")
|
||||
|
||||
curr_cid_title = "无"
|
||||
if cid:
|
||||
@@ -75,27 +80,10 @@ class PersonaCommands:
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
provider_settings = self.context.get_config(umo=umo).get(
|
||||
"provider_settings",
|
||||
{},
|
||||
)
|
||||
(
|
||||
persona_id,
|
||||
_,
|
||||
force_applied_persona_id,
|
||||
_,
|
||||
) = await self.context.persona_manager.resolve_selected_persona(
|
||||
umo=umo,
|
||||
conversation_persona_id=conv.persona_id,
|
||||
platform_name=message.get_platform_name(),
|
||||
provider_settings=provider_settings,
|
||||
)
|
||||
|
||||
if persona_id == "[%None]":
|
||||
curr_persona_name = "无"
|
||||
elif persona_id:
|
||||
curr_persona_name = persona_id
|
||||
if not conv.persona_id and conv.persona_id != "[%None]":
|
||||
curr_persona_name = default_persona["name"]
|
||||
else:
|
||||
curr_persona_name = conv.persona_id
|
||||
|
||||
if force_applied_persona_id:
|
||||
curr_persona_name = f"{curr_persona_name} (自定义规则)"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.18.3"
|
||||
__version__ = "4.18.2"
|
||||
|
||||
@@ -4,60 +4,19 @@ from ..message import Message
|
||||
class ContextTruncator:
|
||||
"""Context truncator."""
|
||||
|
||||
def _has_tool_calls(self, message: Message) -> bool:
|
||||
"""Check if a message contains tool calls."""
|
||||
return (
|
||||
message.role == "assistant"
|
||||
and message.tool_calls is not None
|
||||
and len(message.tool_calls) > 0
|
||||
)
|
||||
|
||||
def fix_messages(self, messages: list[Message]) -> list[Message]:
|
||||
"""修复消息列表,确保 tool call 和 tool response 的配对关系有效。
|
||||
|
||||
此方法确保:
|
||||
1. 每个 `tool` 消息前面都有一个包含 tool_calls 的 `assistant` 消息
|
||||
2. 每个包含 tool_calls 的 `assistant` 消息后面都有对应的 `tool` 响应
|
||||
|
||||
这是 OpenAI Chat Completions API 规范的要求(Gemini 对此执行严格检查)。
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
fixed_messages: list[Message] = []
|
||||
pending_assistant: Message | None = None
|
||||
pending_tools: list[Message] = []
|
||||
|
||||
def flush_pending_if_valid() -> None:
|
||||
nonlocal pending_assistant, pending_tools
|
||||
if pending_assistant is not None and pending_tools:
|
||||
fixed_messages.append(pending_assistant)
|
||||
fixed_messages.extend(pending_tools)
|
||||
pending_assistant = None
|
||||
pending_tools = []
|
||||
|
||||
for msg in messages:
|
||||
if msg.role == "tool":
|
||||
# 只有在有挂起的 assistant(tool_calls) 时才记录 tool 响应
|
||||
if pending_assistant is not None:
|
||||
pending_tools.append(msg)
|
||||
# else: 孤立的 tool 消息,直接忽略
|
||||
continue
|
||||
|
||||
if self._has_tool_calls(msg):
|
||||
# 遇到新的 assistant(tool_calls) 前,先处理旧的 pending 链
|
||||
flush_pending_if_valid()
|
||||
pending_assistant = msg
|
||||
continue
|
||||
|
||||
# 非 tool,且不含 tool_calls 的消息
|
||||
# 先结束任何 pending 链,再正常追加
|
||||
flush_pending_if_valid()
|
||||
fixed_messages.append(msg)
|
||||
|
||||
# 结束时处理最后一个 pending 链
|
||||
flush_pending_if_valid()
|
||||
|
||||
fixed_messages = []
|
||||
for message in messages:
|
||||
if message.role == "tool":
|
||||
# tool block 前面必须要有 user 和 assistant block
|
||||
if len(fixed_messages) < 2:
|
||||
# 这种情况可能是上下文被截断导致的
|
||||
# 我们直接将之前的上下文都清空
|
||||
fixed_messages = []
|
||||
else:
|
||||
fixed_messages.append(message)
|
||||
else:
|
||||
fixed_messages.append(message)
|
||||
return fixed_messages
|
||||
|
||||
def truncate_by_turns(
|
||||
|
||||
@@ -106,7 +106,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL,
|
||||
FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL,
|
||||
}
|
||||
if runtime in {"local", "local_sandboxed"}:
|
||||
if runtime == "local":
|
||||
return {
|
||||
LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import builtins
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
@@ -9,6 +10,7 @@ import zoneinfo
|
||||
from collections.abc import Coroutine
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from astrbot.api import sp
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.agent.handoff import HandoffTool
|
||||
from astrbot.core.agent.mcp_client import MCPTool
|
||||
@@ -110,7 +112,7 @@ class MainAgentBuildConfig:
|
||||
to prevent LLM output harmful information"""
|
||||
safety_mode_strategy: str = "system_prompt"
|
||||
computer_use_runtime: str = "local"
|
||||
"""The runtime for agent computer use: none, local, local_sandboxed, or sandbox."""
|
||||
"""The runtime for agent computer use: none, local, or sandbox."""
|
||||
sandbox_cfg: dict = field(default_factory=dict)
|
||||
add_cron_tools: bool = True
|
||||
"""This will add cron job management tools to the main agent for proactive cron job execution."""
|
||||
@@ -273,26 +275,47 @@ async def _ensure_persona_and_skills(
|
||||
if not req.conversation:
|
||||
return
|
||||
|
||||
(
|
||||
persona_id,
|
||||
persona,
|
||||
_,
|
||||
use_webchat_special_default,
|
||||
) = await plugin_context.persona_manager.resolve_selected_persona(
|
||||
umo=event.unified_msg_origin,
|
||||
conversation_persona_id=req.conversation.persona_id,
|
||||
platform_name=event.get_platform_name(),
|
||||
provider_settings=cfg,
|
||||
)
|
||||
# get persona ID
|
||||
|
||||
# 1. from session service config - highest priority
|
||||
persona_id = (
|
||||
await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=event.unified_msg_origin,
|
||||
key="session_service_config",
|
||||
default={},
|
||||
)
|
||||
).get("persona_id")
|
||||
|
||||
if not persona_id:
|
||||
# 2. from conversation setting - second priority
|
||||
persona_id = req.conversation.persona_id
|
||||
|
||||
if persona_id == "[%None]":
|
||||
# explicitly set to no persona
|
||||
pass
|
||||
elif persona_id is None:
|
||||
# 3. from config default persona setting - last priority
|
||||
persona_id = cfg.get("default_personality")
|
||||
|
||||
persona = next(
|
||||
builtins.filter(
|
||||
lambda persona: persona["name"] == persona_id,
|
||||
plugin_context.persona_manager.personas_v3,
|
||||
),
|
||||
None,
|
||||
)
|
||||
if persona:
|
||||
# Inject persona system prompt
|
||||
if prompt := persona["prompt"]:
|
||||
req.system_prompt += f"\n# Persona Instructions\n\n{prompt}\n"
|
||||
if begin_dialogs := copy.deepcopy(persona.get("_begin_dialogs_processed")):
|
||||
req.contexts[:0] = begin_dialogs
|
||||
elif use_webchat_special_default:
|
||||
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
|
||||
else:
|
||||
# special handling for webchat persona
|
||||
if event.get_platform_name() == "webchat" and persona_id != "[%None]":
|
||||
persona_id = "_chatui_default_"
|
||||
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
|
||||
|
||||
# Inject skills prompt
|
||||
runtime = cfg.get("computer_use_runtime", "local")
|
||||
@@ -1050,7 +1073,7 @@ async def build_main_agent(
|
||||
|
||||
if config.computer_use_runtime == "sandbox":
|
||||
_apply_sandbox_tools(config, req, req.session_id)
|
||||
elif config.computer_use_runtime in {"local", "local_sandboxed"}:
|
||||
elif config.computer_use_runtime == "local":
|
||||
_apply_local_env_tools(req)
|
||||
|
||||
agent_runner = AgentRunner()
|
||||
|
||||
@@ -2,24 +2,22 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_root
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_data_path,
|
||||
get_astrbot_root,
|
||||
get_astrbot_temp_path,
|
||||
)
|
||||
|
||||
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
|
||||
from .base import ComputerBooter
|
||||
|
||||
SandboxBackend = Literal["none", "bwrap", "seatbelt"]
|
||||
|
||||
_BLOCKED_COMMAND_PATTERNS = [
|
||||
" rm -rf ",
|
||||
" rm -fr ",
|
||||
@@ -42,132 +40,20 @@ def _is_safe_command(command: str) -> bool:
|
||||
return not any(pat in cmd for pat in _BLOCKED_COMMAND_PATTERNS)
|
||||
|
||||
|
||||
def _escape_seatbelt_string(raw: str) -> str:
|
||||
return raw.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def _session_workspace_name(session_id: str) -> str:
|
||||
safe_prefix = re.sub(r"[^A-Za-z0-9._-]+", "_", session_id).strip("._-")
|
||||
if not safe_prefix:
|
||||
safe_prefix = "session"
|
||||
safe_prefix = safe_prefix[:40]
|
||||
suffix = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex[:12]
|
||||
return f"{safe_prefix}_{suffix}"
|
||||
|
||||
|
||||
def _detect_sandbox_backend() -> SandboxBackend:
|
||||
if sys.platform.startswith("linux"):
|
||||
if shutil.which("bwrap"):
|
||||
return "bwrap"
|
||||
raise RuntimeError("Local runtime requires 'bwrap' on Linux.")
|
||||
|
||||
if sys.platform == "darwin":
|
||||
if shutil.which("sandbox-exec"):
|
||||
return "seatbelt"
|
||||
raise RuntimeError("Local runtime requires 'sandbox-exec' on macOS.")
|
||||
|
||||
return "none"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalSandboxPolicy:
|
||||
workspace: Path
|
||||
backend: SandboxBackend
|
||||
sandboxed: bool
|
||||
default_cwd: Path
|
||||
|
||||
@classmethod
|
||||
def build_default(cls, session_id: str, sandboxed: bool) -> LocalSandboxPolicy:
|
||||
workspace_root_raw = os.environ.get(
|
||||
"ASTRBOT_LOCAL_WORKSPACE_ROOT"
|
||||
) or os.environ.get("ASTRBOT_LOCAL_WORKSPACE", "~/.astrbot/workspace")
|
||||
workspace_root = Path(workspace_root_raw).expanduser().resolve()
|
||||
workspace = workspace_root / _session_workspace_name(session_id)
|
||||
default_cwd = workspace if sandboxed else Path(get_astrbot_root()).resolve()
|
||||
return cls(
|
||||
workspace=workspace,
|
||||
backend=_detect_sandbox_backend() if sandboxed else "none",
|
||||
sandboxed=sandboxed,
|
||||
default_cwd=default_cwd,
|
||||
)
|
||||
|
||||
def ensure_workspace(self) -> None:
|
||||
try:
|
||||
self.workspace.mkdir(parents=True, exist_ok=True)
|
||||
except PermissionError as exc:
|
||||
raise RuntimeError(
|
||||
"Cannot create local workspace. "
|
||||
"Set ASTRBOT_LOCAL_WORKSPACE_ROOT to a writable path."
|
||||
) from exc
|
||||
|
||||
def resolve_path(self, path: str, base: Path | None = None) -> Path:
|
||||
raw = Path(path).expanduser()
|
||||
resolved = raw if raw.is_absolute() else (base or self.default_cwd) / raw
|
||||
return resolved.resolve()
|
||||
|
||||
def ensure_writable_path(self, path: str) -> Path:
|
||||
abs_path = self.resolve_path(path)
|
||||
if self.sandboxed and not abs_path.is_relative_to(self.workspace):
|
||||
raise PermissionError(
|
||||
f"Write path is outside workspace: {self.workspace.as_posix()}"
|
||||
)
|
||||
return abs_path
|
||||
|
||||
def normalize_working_dir(self, cwd: str | None) -> Path:
|
||||
target = self.resolve_path(cwd) if cwd else self.default_cwd
|
||||
if not target.exists():
|
||||
raise FileNotFoundError(f"Working directory does not exist: {target}")
|
||||
if not target.is_dir():
|
||||
raise NotADirectoryError(f"Working directory is not a directory: {target}")
|
||||
return target
|
||||
|
||||
def wrap_command(self, command: list[str], working_dir: Path) -> list[str]:
|
||||
if not self.sandboxed:
|
||||
return command
|
||||
|
||||
if self.backend == "bwrap":
|
||||
return [
|
||||
"bwrap",
|
||||
"--die-with-parent",
|
||||
"--new-session",
|
||||
"--ro-bind",
|
||||
"/",
|
||||
"/",
|
||||
"--bind",
|
||||
str(self.workspace),
|
||||
str(self.workspace),
|
||||
"--proc",
|
||||
"/proc",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--chdir",
|
||||
str(working_dir),
|
||||
"--",
|
||||
*command,
|
||||
]
|
||||
|
||||
if self.backend == "seatbelt":
|
||||
workspace_escaped = _escape_seatbelt_string(str(self.workspace))
|
||||
profile = "\n".join(
|
||||
[
|
||||
"(version 1)",
|
||||
"(deny default)",
|
||||
'(import "system.sb")',
|
||||
"(allow process*)",
|
||||
"(allow file-read*)",
|
||||
f'(allow file-write* (subpath "{workspace_escaped}"))',
|
||||
"(allow network*)",
|
||||
]
|
||||
)
|
||||
return ["sandbox-exec", "-p", profile, *command]
|
||||
|
||||
raise RuntimeError("Sandbox backend is not available for local_sandboxed mode.")
|
||||
def _ensure_safe_path(path: str) -> str:
|
||||
abs_path = os.path.abspath(path)
|
||||
allowed_roots = [
|
||||
os.path.abspath(get_astrbot_root()),
|
||||
os.path.abspath(get_astrbot_data_path()),
|
||||
os.path.abspath(get_astrbot_temp_path()),
|
||||
]
|
||||
if not any(abs_path.startswith(root) for root in allowed_roots):
|
||||
raise PermissionError("Path is outside the allowed computer roots.")
|
||||
return abs_path
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalShellComponent(ShellComponent):
|
||||
policy: LocalSandboxPolicy
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
command: str,
|
||||
@@ -181,58 +67,41 @@ class LocalShellComponent(ShellComponent):
|
||||
raise PermissionError("Blocked unsafe shell command.")
|
||||
|
||||
def _run() -> dict[str, Any]:
|
||||
shell_command = (
|
||||
["/bin/sh", "-lc", command] if shell else shlex.split(command)
|
||||
)
|
||||
run_env = os.environ.copy()
|
||||
if env:
|
||||
run_env.update({str(k): str(v) for k, v in env.items()})
|
||||
|
||||
working_dir = self.policy.normalize_working_dir(cwd)
|
||||
wrapped_command = self.policy.wrap_command(shell_command, working_dir)
|
||||
working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
|
||||
if background:
|
||||
proc = subprocess.Popen(
|
||||
wrapped_command,
|
||||
shell=False,
|
||||
command,
|
||||
shell=shell,
|
||||
cwd=working_dir,
|
||||
env=run_env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
wrapped_command,
|
||||
shell=False,
|
||||
cwd=working_dir,
|
||||
env=run_env,
|
||||
timeout=timeout,
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"exit_code": result.returncode,
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
timeout_seconds = timeout if timeout is not None else "configured"
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": f"Execution timed out after {timeout_seconds} seconds.",
|
||||
"exit_code": 124,
|
||||
}
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=shell,
|
||||
cwd=working_dir,
|
||||
env=run_env,
|
||||
timeout=timeout,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"exit_code": result.returncode,
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalPythonComponent(PythonComponent):
|
||||
policy: LocalSandboxPolicy
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
code: str,
|
||||
@@ -241,13 +110,9 @@ class LocalPythonComponent(PythonComponent):
|
||||
silent: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
python_command = [os.environ.get("PYTHON", sys.executable), "-c", code]
|
||||
working_dir = self.policy.normalize_working_dir(None)
|
||||
wrapped_command = self.policy.wrap_command(python_command, working_dir)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
wrapped_command,
|
||||
cwd=working_dir,
|
||||
[os.environ.get("PYTHON", sys.executable), "-c", code],
|
||||
timeout=timeout,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -273,25 +138,23 @@ class LocalPythonComponent(PythonComponent):
|
||||
|
||||
@dataclass
|
||||
class LocalFileSystemComponent(FileSystemComponent):
|
||||
policy: LocalSandboxPolicy
|
||||
|
||||
async def create_file(
|
||||
self, path: str, content: str = "", mode: int = 0o644
|
||||
) -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
abs_path = self.policy.ensure_writable_path(path)
|
||||
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with abs_path.open("w", encoding="utf-8") as f:
|
||||
abs_path = _ensure_safe_path(path)
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
with open(abs_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
abs_path.chmod(mode)
|
||||
return {"success": True, "path": str(abs_path)}
|
||||
os.chmod(abs_path, mode)
|
||||
return {"success": True, "path": abs_path}
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
abs_path = self.policy.resolve_path(path)
|
||||
with abs_path.open(encoding=encoding) as f:
|
||||
abs_path = _ensure_safe_path(path)
|
||||
with open(abs_path, encoding=encoding) as f:
|
||||
content = f.read()
|
||||
return {"success": True, "content": content}
|
||||
|
||||
@@ -301,22 +164,22 @@ class LocalFileSystemComponent(FileSystemComponent):
|
||||
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
|
||||
) -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
abs_path = self.policy.ensure_writable_path(path)
|
||||
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with abs_path.open(mode, encoding=encoding) as f:
|
||||
abs_path = _ensure_safe_path(path)
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
with open(abs_path, mode, encoding=encoding) as f:
|
||||
f.write(content)
|
||||
return {"success": True, "path": str(abs_path)}
|
||||
return {"success": True, "path": abs_path}
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
async def delete_file(self, path: str) -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
abs_path = self.policy.ensure_writable_path(path)
|
||||
if abs_path.is_dir():
|
||||
abs_path = _ensure_safe_path(path)
|
||||
if os.path.isdir(abs_path):
|
||||
shutil.rmtree(abs_path)
|
||||
else:
|
||||
abs_path.unlink()
|
||||
return {"success": True, "path": str(abs_path)}
|
||||
os.remove(abs_path)
|
||||
return {"success": True, "path": abs_path}
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
@@ -324,8 +187,8 @@ class LocalFileSystemComponent(FileSystemComponent):
|
||||
self, path: str = ".", show_hidden: bool = False
|
||||
) -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
abs_path = self.policy.resolve_path(path)
|
||||
entries = [entry.name for entry in abs_path.iterdir()]
|
||||
abs_path = _ensure_safe_path(path)
|
||||
entries = os.listdir(abs_path)
|
||||
if not show_hidden:
|
||||
entries = [e for e in entries if not e.startswith(".")]
|
||||
return {"success": True, "entries": entries}
|
||||
@@ -334,28 +197,13 @@ class LocalFileSystemComponent(FileSystemComponent):
|
||||
|
||||
|
||||
class LocalBooter(ComputerBooter):
|
||||
def __init__(self, session_id: str, sandboxed: bool = False) -> None:
|
||||
self._session_id = session_id
|
||||
self._policy = LocalSandboxPolicy.build_default(
|
||||
session_id=session_id, sandboxed=sandboxed
|
||||
)
|
||||
if sandboxed:
|
||||
self._policy.ensure_workspace()
|
||||
if sandboxed and self._policy.backend == "none":
|
||||
logger.warning(
|
||||
f"Local runtime sandbox backend is unavailable on {sys.platform}. "
|
||||
"Only filesystem tools are restricted to workspace."
|
||||
)
|
||||
self._fs = LocalFileSystemComponent(policy=self._policy)
|
||||
self._python = LocalPythonComponent(policy=self._policy)
|
||||
self._shell = LocalShellComponent(policy=self._policy)
|
||||
def __init__(self) -> None:
|
||||
self._fs = LocalFileSystemComponent()
|
||||
self._python = LocalPythonComponent()
|
||||
self._shell = LocalShellComponent()
|
||||
|
||||
async def boot(self, session_id: str) -> None:
|
||||
logger.info(
|
||||
f"Local computer booter initialized for session: {session_id} "
|
||||
f"(sandboxed={self._policy.sandboxed}, "
|
||||
f"backend={self._policy.backend}, workspace={self._policy.workspace})"
|
||||
)
|
||||
logger.info(f"Local computer booter initialized for session: {session_id}")
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
logger.info("Local computer booter shutdown complete.")
|
||||
|
||||
@@ -15,7 +15,7 @@ from .booters.base import ComputerBooter
|
||||
from .booters.local import LocalBooter
|
||||
|
||||
session_booter: dict[str, ComputerBooter] = {}
|
||||
local_booters: dict[tuple[str, bool], ComputerBooter] = {}
|
||||
local_booter: ComputerBooter | None = None
|
||||
|
||||
|
||||
async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None:
|
||||
@@ -104,8 +104,8 @@ async def get_booter(
|
||||
return session_booter[session_id]
|
||||
|
||||
|
||||
def get_local_booter(session_id: str, sandboxed: bool = False) -> ComputerBooter:
|
||||
key = (session_id, sandboxed)
|
||||
if key not in local_booters:
|
||||
local_booters[key] = LocalBooter(session_id=session_id, sandboxed=sandboxed)
|
||||
return local_booters[key]
|
||||
def get_local_booter() -> ComputerBooter:
|
||||
global local_booter
|
||||
if local_booter is None:
|
||||
local_booter = LocalBooter()
|
||||
return local_booter
|
||||
|
||||
@@ -83,10 +83,7 @@ class PythonTool(FunctionTool):
|
||||
@dataclass
|
||||
class LocalPythonTool(FunctionTool):
|
||||
name: str = "astrbot_execute_python"
|
||||
description: str = (
|
||||
"Execute code in a local Python environment. "
|
||||
"In local_sandboxed runtime, writes are restricted to ~/.astrbot/workspace/<session>."
|
||||
)
|
||||
description: str = "Execute codes in a Python environment."
|
||||
|
||||
parameters: dict = field(default_factory=lambda: param_schema)
|
||||
|
||||
@@ -95,15 +92,7 @@ class LocalPythonTool(FunctionTool):
|
||||
) -> ToolExecResult:
|
||||
if permission_error := check_admin_permission(context, "Python execution"):
|
||||
return permission_error
|
||||
event = context.context.event
|
||||
cfg = context.context.context.get_config(umo=event.unified_msg_origin)
|
||||
runtime = str(
|
||||
cfg.get("provider_settings", {}).get("computer_use_runtime", "local")
|
||||
)
|
||||
sb = get_local_booter(
|
||||
event.unified_msg_origin,
|
||||
sandboxed=runtime == "local_sandboxed",
|
||||
)
|
||||
sb = get_local_booter()
|
||||
try:
|
||||
result = await sb.python.exec(code, silent=silent)
|
||||
return await handle_result(result, context.context.event)
|
||||
|
||||
@@ -13,21 +13,14 @@ from .permissions import check_admin_permission
|
||||
@dataclass
|
||||
class ExecuteShellTool(FunctionTool):
|
||||
name: str = "astrbot_execute_shell"
|
||||
description: str = (
|
||||
"Execute a command in the shell. "
|
||||
"In local_sandboxed runtime, writes are restricted to ~/.astrbot/workspace/<session>."
|
||||
)
|
||||
description: str = "Execute a command in the shell."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The shell command to execute.",
|
||||
},
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"description": "Optional working directory for command execution.",
|
||||
"description": "The bash command to execute. Equal to 'cd {working_dir} && {your_command}'.",
|
||||
},
|
||||
"background": {
|
||||
"type": "boolean",
|
||||
@@ -51,36 +44,21 @@ class ExecuteShellTool(FunctionTool):
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
command: str,
|
||||
cwd: str | None = None,
|
||||
background: bool = False,
|
||||
env: dict = {},
|
||||
) -> ToolExecResult:
|
||||
if permission_error := check_admin_permission(context, "Shell execution"):
|
||||
return permission_error
|
||||
|
||||
event = context.context.event
|
||||
cfg = context.context.context.get_config(umo=event.unified_msg_origin)
|
||||
runtime = str(
|
||||
cfg.get("provider_settings", {}).get("computer_use_runtime", "local")
|
||||
)
|
||||
|
||||
if self.is_local:
|
||||
sb = get_local_booter(
|
||||
event.unified_msg_origin,
|
||||
sandboxed=runtime == "local_sandboxed",
|
||||
)
|
||||
sb = get_local_booter()
|
||||
else:
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
event.unified_msg_origin,
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
try:
|
||||
result = await sb.shell.exec(
|
||||
command,
|
||||
cwd=cwd,
|
||||
background=background,
|
||||
env=env,
|
||||
)
|
||||
result = await sb.shell.exec(command, background=background, env=env)
|
||||
return json.dumps(result)
|
||||
except Exception as e:
|
||||
return f"Error executing command: {str(e)}"
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.18.3"
|
||||
VERSION = "4.18.2"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -425,15 +425,7 @@ CONFIG_METADATA_2 = {
|
||||
"slack_webhook_port": 6197,
|
||||
"slack_webhook_path": "/astrbot-slack-webhook/callback",
|
||||
},
|
||||
"Line": {
|
||||
"id": "line",
|
||||
"type": "line",
|
||||
"enable": False,
|
||||
"channel_access_token": "",
|
||||
"channel_secret": "",
|
||||
"unified_webhook_mode": True,
|
||||
"webhook_uuid": "",
|
||||
},
|
||||
# LINE's config is located in line_adapter.py
|
||||
"Satori": {
|
||||
"id": "satori",
|
||||
"type": "satori",
|
||||
@@ -1471,7 +1463,6 @@ CONFIG_METADATA_2 = {
|
||||
"type": "openai_embedding",
|
||||
"provider": "openai",
|
||||
"provider_type": "embedding",
|
||||
"hint": "provider_group.provider.openai_embedding.hint",
|
||||
"enable": True,
|
||||
"embedding_api_key": "",
|
||||
"embedding_api_base": "",
|
||||
@@ -1485,7 +1476,6 @@ CONFIG_METADATA_2 = {
|
||||
"type": "gemini_embedding",
|
||||
"provider": "google",
|
||||
"provider_type": "embedding",
|
||||
"hint": "provider_group.provider.gemini_embedding.hint",
|
||||
"enable": True,
|
||||
"embedding_api_key": "",
|
||||
"embedding_api_base": "",
|
||||
@@ -2202,9 +2192,9 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
},
|
||||
"proxy": {
|
||||
"description": "provider_group.provider.proxy.description",
|
||||
"description": "代理地址",
|
||||
"type": "string",
|
||||
"hint": "provider_group.provider.proxy.hint",
|
||||
"hint": "HTTP/HTTPS 代理地址,格式如 http://127.0.0.1:7890。仅对该提供商的 API 请求生效,不影响 Docker 内网通信。",
|
||||
},
|
||||
"model": {
|
||||
"description": "模型 ID",
|
||||
@@ -2772,8 +2762,8 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.computer_use_runtime": {
|
||||
"description": "Computer Use Runtime",
|
||||
"type": "string",
|
||||
"options": ["none", "local", "local_sandboxed", "sandbox"],
|
||||
"labels": ["无", "本地", "本地(沙箱增强)", "沙箱"],
|
||||
"options": ["none", "local", "sandbox"],
|
||||
"labels": ["无", "本地", "沙箱"],
|
||||
"hint": "选择 Computer Use 运行环境。",
|
||||
},
|
||||
"provider_settings.computer_use_require_admin": {
|
||||
|
||||
@@ -4,7 +4,7 @@ import typing as T
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import CursorResult, Row
|
||||
from sqlalchemy import CursorResult
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import col, delete, desc, func, or_, select, text, update
|
||||
|
||||
@@ -626,7 +626,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
query = select(ApiKey).where(
|
||||
ApiKey.key_hash == key_hash,
|
||||
col(ApiKey.revoked_at).is_(None),
|
||||
or_(col(ApiKey.expires_at).is_(None), col(ApiKey.expires_at) > now),
|
||||
or_(col(ApiKey.expires_at).is_(None), ApiKey.expires_at > now),
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -638,7 +638,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.key_id) == key_id)
|
||||
.where(ApiKey.key_id == key_id)
|
||||
.values(last_used_at=datetime.now(timezone.utc)),
|
||||
)
|
||||
|
||||
@@ -649,7 +649,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
async with session.begin():
|
||||
query = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.key_id) == key_id)
|
||||
.where(ApiKey.key_id == key_id)
|
||||
.values(revoked_at=datetime.now(timezone.utc))
|
||||
)
|
||||
result = T.cast(CursorResult, await session.execute(query))
|
||||
@@ -663,7 +663,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
result = T.cast(
|
||||
CursorResult,
|
||||
await session.execute(
|
||||
delete(ApiKey).where(col(ApiKey.key_id) == key_id)
|
||||
delete(ApiKey).where(ApiKey.key_id == key_id)
|
||||
),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
@@ -1457,7 +1457,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
return query
|
||||
|
||||
@staticmethod
|
||||
def _rows_to_session_dicts(rows: T.Sequence[Row[tuple]]) -> list[dict]:
|
||||
def _rows_to_session_dicts(rows: list[tuple]) -> list[dict]:
|
||||
sessions_with_projects = []
|
||||
for row in rows:
|
||||
platform_session = row[0]
|
||||
|
||||
@@ -256,46 +256,6 @@ class KBSQLiteDatabase:
|
||||
"knowledge_base": row[1],
|
||||
}
|
||||
|
||||
async def get_documents_with_metadata_batch(
|
||||
self, doc_ids: set[str]
|
||||
) -> dict[str, dict]:
|
||||
"""批量获取文档及其所属知识库元数据
|
||||
|
||||
Args:
|
||||
doc_ids: 文档 ID 集合
|
||||
|
||||
Returns:
|
||||
dict: doc_id -> {"document": KBDocument, "knowledge_base": KnowledgeBase}
|
||||
|
||||
"""
|
||||
if not doc_ids:
|
||||
return {}
|
||||
|
||||
metadata_map: dict[str, dict] = {}
|
||||
# SQLite 参数上限为 999,分片查询避免超限
|
||||
chunk_size = 900
|
||||
doc_id_list = list(doc_ids)
|
||||
|
||||
async with self.get_db() as session:
|
||||
for i in range(0, len(doc_id_list), chunk_size):
|
||||
chunk = doc_id_list[i : i + chunk_size]
|
||||
stmt = (
|
||||
select(KBDocument, KnowledgeBase)
|
||||
.join(
|
||||
KnowledgeBase,
|
||||
col(KBDocument.kb_id) == col(KnowledgeBase.kb_id),
|
||||
)
|
||||
.where(col(KBDocument.doc_id).in_(chunk))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
for row in result.all():
|
||||
metadata_map[row[0].doc_id] = {
|
||||
"document": row[0],
|
||||
"knowledge_base": row[1],
|
||||
}
|
||||
|
||||
return metadata_map
|
||||
|
||||
async def delete_document_by_id(self, doc_id: str, vec_db: FaissVecDB) -> None:
|
||||
"""删除单个文档及其相关数据"""
|
||||
# 在知识库表中删除
|
||||
|
||||
@@ -142,13 +142,10 @@ class RetrievalManager:
|
||||
f"Rank fusion took {time_end - time_start:.2f}s and returned {len(fused_results)} results.",
|
||||
)
|
||||
|
||||
# 4. 转换为 RetrievalResult (批量获取元数据)
|
||||
doc_ids = {fr.doc_id for fr in fused_results}
|
||||
metadata_map = await self.kb_db.get_documents_with_metadata_batch(doc_ids)
|
||||
|
||||
# 4. 转换为 RetrievalResult (获取元数据)
|
||||
retrieval_results = []
|
||||
for fr in fused_results:
|
||||
metadata_dict = metadata_map.get(fr.doc_id)
|
||||
metadata_dict = await self.kb_db.get_document_with_metadata(fr.doc_id)
|
||||
if metadata_dict:
|
||||
retrieval_results.append(
|
||||
RetrievalResult(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from astrbot import logger
|
||||
from astrbot.api import sp
|
||||
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import Persona, PersonaFolder, Personality
|
||||
@@ -59,60 +58,6 @@ class PersonaManager:
|
||||
except Exception:
|
||||
return DEFAULT_PERSONALITY
|
||||
|
||||
async def resolve_selected_persona(
|
||||
self,
|
||||
*,
|
||||
umo: str | MessageSession,
|
||||
conversation_persona_id: str | None,
|
||||
platform_name: str,
|
||||
provider_settings: dict | None = None,
|
||||
) -> tuple[str | None, Personality | None, str | None, bool]:
|
||||
"""解析当前会话最终生效的人格。
|
||||
|
||||
Returns:
|
||||
tuple:
|
||||
- selected persona_id
|
||||
- selected persona object
|
||||
- force applied persona_id from session rule
|
||||
- whether use webchat special default persona
|
||||
"""
|
||||
session_service_config = (
|
||||
await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=str(umo),
|
||||
key="session_service_config",
|
||||
default={},
|
||||
)
|
||||
or {}
|
||||
)
|
||||
|
||||
force_applied_persona_id = session_service_config.get("persona_id")
|
||||
persona_id = force_applied_persona_id
|
||||
|
||||
if not persona_id:
|
||||
persona_id = conversation_persona_id
|
||||
if persona_id == "[%None]":
|
||||
pass
|
||||
elif persona_id is None:
|
||||
persona_id = (provider_settings or {}).get("default_personality")
|
||||
|
||||
persona = next(
|
||||
(item for item in self.personas_v3 if item["name"] == persona_id),
|
||||
None,
|
||||
)
|
||||
|
||||
use_webchat_special_default = False
|
||||
if not persona and platform_name == "webchat" and persona_id != "[%None]":
|
||||
persona_id = "_chatui_default_"
|
||||
use_webchat_special_default = True
|
||||
|
||||
return (
|
||||
persona_id,
|
||||
persona,
|
||||
force_applied_persona_id,
|
||||
use_webchat_special_default,
|
||||
)
|
||||
|
||||
async def delete_persona(self, persona_id: str) -> None:
|
||||
"""删除指定 persona"""
|
||||
if not await self.db.get_persona_by_id(persona_id):
|
||||
|
||||
@@ -8,7 +8,7 @@ resolution for backward compatibility.
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from astrbot.core.message.message_event_result import (
|
||||
EventResultType,
|
||||
@@ -17,17 +17,6 @@ from astrbot.core.message.message_event_result import (
|
||||
|
||||
from .stage_order import STAGES_ORDER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .content_safety_check.stage import ContentSafetyCheckStage
|
||||
from .preprocess_stage.stage import PreProcessStage
|
||||
from .process_stage.stage import ProcessStage
|
||||
from .rate_limit_check.stage import RateLimitStage
|
||||
from .respond.stage import RespondStage
|
||||
from .result_decorate.stage import ResultDecorateStage
|
||||
from .session_status_check.stage import SessionStatusCheckStage
|
||||
from .waking_check.stage import WakingCheckStage
|
||||
from .whitelist_check.stage import WhitelistCheckStage
|
||||
|
||||
_LAZY_EXPORTS = {
|
||||
"ContentSafetyCheckStage": (
|
||||
"astrbot.core.pipeline.content_safety_check.stage",
|
||||
|
||||
@@ -180,9 +180,9 @@ class PlatformManager:
|
||||
from .sources.line.line_adapter import (
|
||||
LinePlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "email":
|
||||
from .sources.email.email_adapter import (
|
||||
EmailPlatformAdapter, # noqa: F401
|
||||
case "heihe":
|
||||
from .sources.heihe.heihe_adapter import (
|
||||
HeihePlatformAdapter, # noqa: F401
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.error(
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
import websockets
|
||||
from websockets.asyncio.client import ClientConnection, connect
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, Image, Plain
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
Group,
|
||||
MessageMember,
|
||||
MessageType,
|
||||
Platform,
|
||||
PlatformMetadata,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
from .heihe_event import HeiheMessageEvent
|
||||
|
||||
HEIHE_CONFIG_METADATA = {
|
||||
"heihe_ws_url": {
|
||||
"description": "Heihe WebSocket URL",
|
||||
"type": "string",
|
||||
"hint": "一般情况下不需要修改。",
|
||||
},
|
||||
"heihe_token": {
|
||||
"description": "Bot Token",
|
||||
"type": "string",
|
||||
"hint": "黑盒 Bot Token。可填写纯 Token(推荐),适配器会自动添加 Authorization 头。",
|
||||
},
|
||||
"heihe_origin": {
|
||||
"description": "WebSocket Origin",
|
||||
"type": "string",
|
||||
"hint": "用于 WebSocket 握手的 Origin 头,默认 https://chat.xiaoheihe.cn。",
|
||||
},
|
||||
"heihe_bot_id": {
|
||||
"description": "Bot ID",
|
||||
"type": "string",
|
||||
"hint": "可选。为空时会根据收到的消息自动识别机器人 ID。",
|
||||
},
|
||||
"heihe_auto_reconnect": {
|
||||
"description": "Auto Reconnect",
|
||||
"type": "bool",
|
||||
"hint": "WebSocket 断开后是否自动重连。",
|
||||
},
|
||||
"heihe_heartbeat_interval": {
|
||||
"description": "Heartbeat Interval (seconds)",
|
||||
"type": "int",
|
||||
"hint": "发送心跳包间隔。<=0 表示关闭主动心跳。",
|
||||
},
|
||||
"heihe_reconnect_delay": {
|
||||
"description": "Reconnect Delay (seconds)",
|
||||
"type": "int",
|
||||
"hint": "WebSocket 断开后的重连等待时间。",
|
||||
},
|
||||
"heihe_ignore_self_message": {
|
||||
"description": "Ignore Self Message",
|
||||
"type": "bool",
|
||||
"hint": "是否忽略机器人自身发送的消息。",
|
||||
},
|
||||
}
|
||||
|
||||
HEIHE_I18N_RESOURCES = {
|
||||
"zh-CN": {
|
||||
"heihe_ws_url": {
|
||||
"description": "黑盒 WebSocket 地址",
|
||||
"hint": "一般情况下不需要修改。",
|
||||
},
|
||||
"heihe_token": {
|
||||
"description": "机器人 Token",
|
||||
"hint": "建议填写纯 Token,适配器会自动补齐 Authorization 头。",
|
||||
},
|
||||
"heihe_origin": {
|
||||
"description": "WebSocket Origin",
|
||||
"hint": "用于握手的 Origin 头,默认 https://chat.xiaoheihe.cn。",
|
||||
},
|
||||
"heihe_bot_id": {
|
||||
"description": "机器人 ID",
|
||||
"hint": "可选。为空时会根据收到的消息自动识别机器人 ID。",
|
||||
},
|
||||
"heihe_auto_reconnect": {
|
||||
"description": "自动重连",
|
||||
"hint": "WebSocket 断开后是否自动重连。",
|
||||
},
|
||||
"heihe_heartbeat_interval": {
|
||||
"description": "心跳间隔(秒)",
|
||||
"hint": "设置 <=0 将关闭主动心跳。",
|
||||
},
|
||||
"heihe_reconnect_delay": {
|
||||
"description": "重连间隔(秒)",
|
||||
"hint": "WebSocket 断开后的重连等待时间。",
|
||||
},
|
||||
"heihe_ignore_self_message": {
|
||||
"description": "忽略机器人自身消息",
|
||||
"hint": "开启后,机器人自己发出的消息将不会触发事件处理。",
|
||||
},
|
||||
},
|
||||
"en-US": {
|
||||
"heihe_ws_url": {
|
||||
"description": "Heihe WebSocket URL",
|
||||
"hint": "Usually no need to change this.",
|
||||
},
|
||||
"heihe_token": {
|
||||
"description": "Bot Token",
|
||||
"hint": "Plain token is recommended. Authorization header is added automatically.",
|
||||
},
|
||||
"heihe_origin": {
|
||||
"description": "WebSocket Origin",
|
||||
"hint": "Origin header used in websocket handshake. Default: https://chat.xiaoheihe.cn.",
|
||||
},
|
||||
"heihe_bot_id": {
|
||||
"description": "Bot ID",
|
||||
"hint": "Optional. If empty, the adapter will infer it from incoming messages.",
|
||||
},
|
||||
"heihe_auto_reconnect": {
|
||||
"description": "Auto Reconnect",
|
||||
"hint": "Whether to reconnect automatically after websocket disconnects.",
|
||||
},
|
||||
"heihe_heartbeat_interval": {
|
||||
"description": "Heartbeat Interval (seconds)",
|
||||
"hint": "Set <=0 to disable active heartbeat.",
|
||||
},
|
||||
"heihe_reconnect_delay": {
|
||||
"description": "Reconnect Delay (seconds)",
|
||||
"hint": "Delay before reconnecting after disconnect.",
|
||||
},
|
||||
"heihe_ignore_self_message": {
|
||||
"description": "Ignore Self Message",
|
||||
"hint": "When enabled, messages sent by the bot itself will be ignored.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
"heihe",
|
||||
"黑盒机器人(WebSocket)适配器",
|
||||
support_streaming_message=False,
|
||||
default_config_tmpl={
|
||||
"id": "heihe",
|
||||
"type": "heihe",
|
||||
"enable": False,
|
||||
"heihe_ws_url": "wss://chat.xiaoheihe.cn/chatroom/ws/connect",
|
||||
"heihe_token": "",
|
||||
"heihe_origin": "https://chat.xiaoheihe.cn",
|
||||
"heihe_bot_id": "",
|
||||
"heihe_auto_reconnect": True,
|
||||
"heihe_heartbeat_interval": 20,
|
||||
"heihe_reconnect_delay": 5,
|
||||
"heihe_ignore_self_message": True,
|
||||
},
|
||||
config_metadata=HEIHE_CONFIG_METADATA,
|
||||
i18n_resources=HEIHE_I18N_RESOURCES,
|
||||
)
|
||||
class HeihePlatformAdapter(Platform):
|
||||
def __init__(
|
||||
self,
|
||||
platform_config: dict,
|
||||
platform_settings: dict,
|
||||
event_queue: asyncio.Queue,
|
||||
) -> None:
|
||||
super().__init__(platform_config, event_queue)
|
||||
self.settings = platform_settings
|
||||
|
||||
self.ws_url = str(platform_config.get("heihe_ws_url", "")).strip()
|
||||
self.token = str(platform_config.get("heihe_token", "")).strip()
|
||||
self.origin = str(
|
||||
platform_config.get("heihe_origin", "https://chat.xiaoheihe.cn"),
|
||||
).strip()
|
||||
self.bot_id = str(platform_config.get("heihe_bot_id", "")).strip()
|
||||
self.auto_reconnect = bool(platform_config.get("heihe_auto_reconnect", True))
|
||||
self.heartbeat_interval = int(
|
||||
cast(int, platform_config.get("heihe_heartbeat_interval", 20)),
|
||||
)
|
||||
self.reconnect_delay = int(
|
||||
cast(int, platform_config.get("heihe_reconnect_delay", 5)),
|
||||
)
|
||||
self.ignore_self_message = bool(
|
||||
platform_config.get("heihe_ignore_self_message", True),
|
||||
)
|
||||
|
||||
if not self.ws_url:
|
||||
raise ValueError("heihe_ws_url 不能为空。")
|
||||
|
||||
self.metadata = PlatformMetadata(
|
||||
name="heihe",
|
||||
description="黑盒机器人(WebSocket)适配器",
|
||||
id=cast(str, self.config.get("id", "heihe")),
|
||||
support_streaming_message=False,
|
||||
)
|
||||
|
||||
self.ws: ClientConnection | None = None
|
||||
self.running = False
|
||||
self.heartbeat_task: asyncio.Task | None = None
|
||||
self._last_heartbeat_ts = 0
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return self.metadata
|
||||
|
||||
async def run(self) -> None:
|
||||
self.running = True
|
||||
while self.running:
|
||||
try:
|
||||
await self._connect_and_loop()
|
||||
except websockets.exceptions.ConnectionClosed as e:
|
||||
logger.warning("[heihe] websocket disconnected: %s", e)
|
||||
except Exception as e:
|
||||
logger.error("[heihe] websocket failed: %s", e)
|
||||
|
||||
if not self.running:
|
||||
break
|
||||
if not self.auto_reconnect:
|
||||
break
|
||||
await asyncio.sleep(max(1, self.reconnect_delay))
|
||||
|
||||
async def terminate(self) -> None:
|
||||
self.running = False
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
try:
|
||||
await self.heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
|
||||
async def send_by_session(
|
||||
self,
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
await HeiheMessageEvent.send_with_adapter(
|
||||
self,
|
||||
message_chain,
|
||||
session.session_id,
|
||||
)
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
async def send_payload(self, payload: Mapping[str, Any]) -> None:
|
||||
if not self.ws:
|
||||
raise RuntimeError("[heihe] websocket not connected")
|
||||
if self.ws.close_code is not None:
|
||||
raise RuntimeError("[heihe] websocket already closed")
|
||||
|
||||
body = dict(payload)
|
||||
body.setdefault("timestamp", int(time.time()))
|
||||
await self.ws.send(json.dumps(body, ensure_ascii=False))
|
||||
|
||||
async def _connect_and_loop(self) -> None:
|
||||
logger.info("[heihe] connecting websocket: %s", self.ws_url)
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
headers["X-Token"] = self.token
|
||||
|
||||
websocket = await connect(
|
||||
self.ws_url,
|
||||
additional_headers=headers,
|
||||
max_size=10 * 1024 * 1024,
|
||||
ping_interval=None,
|
||||
)
|
||||
self.ws = websocket
|
||||
logger.info("[heihe] websocket connected")
|
||||
|
||||
if self.heartbeat_interval > 0:
|
||||
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
try:
|
||||
async for raw in websocket:
|
||||
await self._handle_incoming(raw)
|
||||
finally:
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
try:
|
||||
await self.heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self.heartbeat_task = None
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
|
||||
async def _heartbeat_loop(self) -> None:
|
||||
try:
|
||||
while self.running and self.ws and self.ws.close_code is None:
|
||||
await asyncio.sleep(self.heartbeat_interval)
|
||||
self._last_heartbeat_ts = int(time.time())
|
||||
await self.send_payload(
|
||||
{
|
||||
"type": "ping",
|
||||
"ping": self._last_heartbeat_ts,
|
||||
},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("[heihe] heartbeat error: %s", e)
|
||||
|
||||
async def _handle_incoming(self, raw: Any) -> None:
|
||||
if isinstance(raw, bytes):
|
||||
try:
|
||||
raw = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return
|
||||
if not isinstance(raw, str):
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("[heihe] skip non-json frame: %s", raw[:200])
|
||||
return
|
||||
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
await self._handle_packet(item)
|
||||
return
|
||||
if isinstance(data, dict):
|
||||
await self._handle_packet(data)
|
||||
|
||||
async def _handle_packet(self, packet: dict[str, Any]) -> None:
|
||||
if "ping" in packet:
|
||||
await self.send_payload({"type": "pong", "pong": packet.get("ping")})
|
||||
return
|
||||
if str(packet.get("type", "")).lower() == "ping":
|
||||
await self.send_payload({"type": "pong", "pong": packet.get("ping")})
|
||||
return
|
||||
|
||||
event_type = str(
|
||||
packet.get("event")
|
||||
or packet.get("event_type")
|
||||
or packet.get("type")
|
||||
or packet.get("topic")
|
||||
or "",
|
||||
).lower()
|
||||
payload_obj = packet.get("data")
|
||||
payload = payload_obj if isinstance(payload_obj, dict) else packet
|
||||
|
||||
if not self._is_message_event(event_type, payload):
|
||||
return
|
||||
|
||||
abm = self._convert_message(payload, packet)
|
||||
if not abm:
|
||||
return
|
||||
await self.handle_msg(abm)
|
||||
|
||||
@staticmethod
|
||||
def _is_message_event(event_type: str, payload: Mapping[str, Any]) -> bool:
|
||||
if "message" in event_type:
|
||||
return True
|
||||
keys = payload.keys()
|
||||
return "content" in keys or "text" in keys or "message" in keys
|
||||
|
||||
def _convert_message(
|
||||
self,
|
||||
payload: Mapping[str, Any],
|
||||
raw_packet: Mapping[str, Any],
|
||||
) -> AstrBotMessage | None:
|
||||
message_obj = payload.get("message")
|
||||
message = message_obj if isinstance(message_obj, Mapping) else payload
|
||||
|
||||
sender_data_obj = (
|
||||
payload.get("sender") or payload.get("author") or payload.get("user") or {}
|
||||
)
|
||||
sender_data = sender_data_obj if isinstance(sender_data_obj, Mapping) else {}
|
||||
sender_id = str(
|
||||
sender_data.get("id")
|
||||
or sender_data.get("user_id")
|
||||
or payload.get("sender_id")
|
||||
or payload.get("user_id")
|
||||
or "",
|
||||
).strip()
|
||||
sender_name = str(
|
||||
sender_data.get("nickname")
|
||||
or sender_data.get("name")
|
||||
or sender_data.get("username")
|
||||
or sender_id
|
||||
or "unknown",
|
||||
)
|
||||
|
||||
self_id = str(
|
||||
payload.get("self_id")
|
||||
or payload.get("bot_id")
|
||||
or self.bot_id
|
||||
or self.meta().id,
|
||||
)
|
||||
if self.ignore_self_message and sender_id and self_id and sender_id == self_id:
|
||||
return None
|
||||
|
||||
channel_id = str(
|
||||
payload.get("channel_id")
|
||||
or payload.get("room_id")
|
||||
or payload.get("chat_id")
|
||||
or payload.get("session_id")
|
||||
or "",
|
||||
).strip()
|
||||
guild_id = str(
|
||||
payload.get("guild_id")
|
||||
or payload.get("server_id")
|
||||
or payload.get("group_id")
|
||||
or "",
|
||||
).strip()
|
||||
is_private = bool(payload.get("is_private", False))
|
||||
if str(payload.get("message_type", "")).lower() in {"private", "friend", "dm"}:
|
||||
is_private = True
|
||||
|
||||
session_id = channel_id or sender_id
|
||||
if not session_id:
|
||||
return None
|
||||
|
||||
text = str(message.get("content") or message.get("text") or "").strip()
|
||||
components = self._build_components(text, payload)
|
||||
if not components:
|
||||
return None
|
||||
|
||||
abm = AstrBotMessage()
|
||||
abm.self_id = self_id
|
||||
abm.message_id = str(
|
||||
message.get("id")
|
||||
or message.get("message_id")
|
||||
or payload.get("message_id")
|
||||
or payload.get("msg_id")
|
||||
or uuid.uuid4().hex
|
||||
)
|
||||
timestamp_raw = (
|
||||
payload.get("timestamp")
|
||||
or payload.get("time")
|
||||
or message.get("timestamp")
|
||||
or message.get("time")
|
||||
)
|
||||
abm.timestamp = int(time.time())
|
||||
if isinstance(timestamp_raw, int):
|
||||
abm.timestamp = (
|
||||
timestamp_raw // 1000
|
||||
if timestamp_raw > 1_000_000_000_000
|
||||
else timestamp_raw
|
||||
)
|
||||
|
||||
if not is_private and (channel_id or guild_id):
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
abm.group = Group(
|
||||
group_id=guild_id or channel_id, group_name=guild_id or ""
|
||||
)
|
||||
else:
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
|
||||
abm.session_id = session_id
|
||||
abm.sender = MessageMember(user_id=sender_id or "unknown", nickname=sender_name)
|
||||
abm.message = components
|
||||
abm.message_str = self._build_message_str(components)
|
||||
abm.raw_message = dict(raw_packet)
|
||||
return abm
|
||||
|
||||
@staticmethod
|
||||
def _build_components(text: str, payload: Mapping[str, Any]) -> list:
|
||||
components: list = []
|
||||
if text:
|
||||
components.append(Plain(text=text))
|
||||
|
||||
mentions_obj = payload.get("mentions")
|
||||
if isinstance(mentions_obj, list):
|
||||
for mention in mentions_obj:
|
||||
if not isinstance(mention, Mapping):
|
||||
continue
|
||||
user_id = str(mention.get("user_id") or mention.get("id") or "").strip()
|
||||
name = str(mention.get("name") or mention.get("nickname") or "").strip()
|
||||
if user_id or name:
|
||||
components.append(At(qq=user_id, name=name))
|
||||
|
||||
attachments_obj = payload.get("attachments")
|
||||
if isinstance(attachments_obj, list):
|
||||
for item in attachments_obj:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
url = str(item.get("url") or item.get("file_url") or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
kind = str(item.get("type") or item.get("media_type") or "").lower()
|
||||
if "image" in kind:
|
||||
components.append(Image.fromURL(url))
|
||||
else:
|
||||
components.append(Plain(text=f"[{kind or 'file'}] {url}"))
|
||||
return components
|
||||
|
||||
@staticmethod
|
||||
def _build_message_str(components: list) -> str:
|
||||
parts: list[str] = []
|
||||
for comp in components:
|
||||
if isinstance(comp, Plain):
|
||||
parts.append(comp.text)
|
||||
elif isinstance(comp, At):
|
||||
parts.append(f"@{comp.name or comp.qq}")
|
||||
elif isinstance(comp, Image):
|
||||
parts.append("[image]")
|
||||
else:
|
||||
parts.append(f"[{comp.type}]")
|
||||
return " ".join(i for i in parts if i).strip()
|
||||
|
||||
async def handle_msg(self, abm: AstrBotMessage) -> None:
|
||||
event = HeiheMessageEvent(
|
||||
message_str=abm.message_str,
|
||||
message_obj=abm,
|
||||
platform_meta=self.meta(),
|
||||
session_id=abm.session_id,
|
||||
adapter=self,
|
||||
)
|
||||
self.commit_event(event)
|
||||
@@ -0,0 +1,108 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import At, Image, Plain, Reply
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .heihe_adapter import HeihePlatformAdapter
|
||||
|
||||
|
||||
class HeiheMessageEvent(AstrMessageEvent):
|
||||
def __init__(
|
||||
self,
|
||||
message_str: str,
|
||||
message_obj,
|
||||
platform_meta,
|
||||
session_id: str,
|
||||
adapter: "HeihePlatformAdapter",
|
||||
) -> None:
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.adapter = adapter
|
||||
|
||||
@classmethod
|
||||
async def send_with_adapter(
|
||||
cls,
|
||||
adapter: "HeihePlatformAdapter",
|
||||
message: MessageChain,
|
||||
session_id: str,
|
||||
) -> None:
|
||||
payload = await cls._build_send_payload(message, session_id)
|
||||
await adapter.send_payload(payload)
|
||||
|
||||
async def send(self, message: MessageChain) -> None:
|
||||
await self.send_with_adapter(self.adapter, message, self.session_id)
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(
|
||||
self,
|
||||
generator: AsyncGenerator,
|
||||
use_fallback: bool = False,
|
||||
):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return None
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@classmethod
|
||||
async def _build_send_payload(
|
||||
cls,
|
||||
message: MessageChain,
|
||||
session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
text_parts: list[str] = []
|
||||
segments: list[dict[str, Any]] = []
|
||||
|
||||
for component in message.chain:
|
||||
if isinstance(component, Plain):
|
||||
if component.text:
|
||||
text_parts.append(component.text)
|
||||
segments.append({"type": "text", "text": component.text})
|
||||
continue
|
||||
|
||||
if isinstance(component, At):
|
||||
at_name = str(component.name or component.qq or "").strip()
|
||||
if at_name:
|
||||
text_parts.append(f"@{at_name}")
|
||||
segments.append(
|
||||
{
|
||||
"type": "mention",
|
||||
"user_id": str(component.qq or ""),
|
||||
"name": at_name,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(component, Reply):
|
||||
if component.id:
|
||||
segments.append({"type": "reply", "message_id": component.id})
|
||||
continue
|
||||
|
||||
if isinstance(component, Image):
|
||||
image_url = ""
|
||||
try:
|
||||
image_url = await component.register_to_file_service()
|
||||
except Exception as e:
|
||||
logger.debug("[heihe] image upload fallback failed: %s", e)
|
||||
|
||||
if image_url:
|
||||
segments.append({"type": "image", "url": image_url})
|
||||
text_parts.append("[image]")
|
||||
continue
|
||||
|
||||
content = "".join(text_parts).strip()
|
||||
payload: dict[str, Any] = {
|
||||
"action": "send_message",
|
||||
"channel_id": session_id,
|
||||
"content": content,
|
||||
"segments": segments,
|
||||
}
|
||||
return payload
|
||||
@@ -65,6 +65,15 @@ LINE_I18N_RESOURCES = {
|
||||
"line",
|
||||
"LINE Messaging API 适配器",
|
||||
support_streaming_message=False,
|
||||
default_config_tmpl={
|
||||
"id": "line",
|
||||
"type": "line",
|
||||
"enable": False,
|
||||
"channel_access_token": "",
|
||||
"channel_secret": "",
|
||||
"unified_webhook_mode": True,
|
||||
"webhook_uuid": "",
|
||||
},
|
||||
config_metadata=LINE_CONFIG_METADATA,
|
||||
i18n_resources=LINE_I18N_RESOURCES,
|
||||
)
|
||||
|
||||
@@ -162,8 +162,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if record_file_path: # group record msg
|
||||
media = await self.upload_group_and_c2c_record(
|
||||
record_file_path,
|
||||
@@ -172,8 +170,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
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
|
||||
@@ -192,8 +188,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if record_file_path: # c2c record
|
||||
media = await self.upload_group_and_c2c_record(
|
||||
record_file_path,
|
||||
@@ -202,8 +196,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if stream:
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.post_c2c_message(
|
||||
|
||||
@@ -3,7 +3,6 @@ import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from typing import cast
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from telegram import BotCommand, Update
|
||||
@@ -28,7 +27,7 @@ from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.star_handler import star_handlers_registry
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot.core.utils.media_utils import convert_audio_to_wav
|
||||
|
||||
from .tg_event import TelegramPlatformEvent
|
||||
@@ -381,10 +380,10 @@ class TelegramPlatformAdapter(Platform):
|
||||
elif update.message.voice:
|
||||
file = await update.message.voice.get_file()
|
||||
|
||||
file_basename = os.path.basename(cast(str, file.file_path))
|
||||
file_basename = os.path.basename(file.file_path)
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
temp_path = os.path.join(temp_dir, file_basename)
|
||||
await download_file(cast(str, file.file_path), path=temp_path)
|
||||
temp_path = await download_image_by_url(file.file_path, path=temp_path)
|
||||
path_wav = os.path.join(
|
||||
temp_dir,
|
||||
f"{file_basename}.wav",
|
||||
|
||||
@@ -18,7 +18,6 @@ from astrbot.api.message_components import (
|
||||
Plain,
|
||||
Record,
|
||||
Reply,
|
||||
Video,
|
||||
)
|
||||
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
|
||||
|
||||
@@ -37,7 +36,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
# 消息类型到 chat action 的映射,用于优先级判断
|
||||
ACTION_BY_TYPE: dict[type, str] = {
|
||||
Record: ChatAction.UPLOAD_VOICE,
|
||||
Video: ChatAction.UPLOAD_VIDEO,
|
||||
File: ChatAction.UPLOAD_DOCUMENT,
|
||||
Image: ChatAction.UPLOAD_PHOTO,
|
||||
Plain: ChatAction.TYPING,
|
||||
@@ -116,18 +114,10 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
**payload: Any,
|
||||
) -> None:
|
||||
"""发送媒体时显示 upload action,发送完成后恢复 typing"""
|
||||
effective_thread_id = message_thread_id or cast(
|
||||
str | None, payload.get("message_thread_id")
|
||||
)
|
||||
await cls._send_chat_action(client, user_name, upload_action, message_thread_id)
|
||||
await send_coro(**payload)
|
||||
await cls._send_chat_action(
|
||||
client, user_name, upload_action, effective_thread_id
|
||||
)
|
||||
send_payload = dict(payload)
|
||||
if effective_thread_id and "message_thread_id" not in send_payload:
|
||||
send_payload["message_thread_id"] = effective_thread_id
|
||||
await send_coro(**send_payload)
|
||||
await cls._send_chat_action(
|
||||
client, user_name, ChatAction.TYPING, effective_thread_id
|
||||
client, user_name, ChatAction.TYPING, message_thread_id
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -151,16 +141,14 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
"""
|
||||
try:
|
||||
if use_media_action:
|
||||
media_payload = dict(payload)
|
||||
if message_thread_id and "message_thread_id" not in media_payload:
|
||||
media_payload["message_thread_id"] = message_thread_id
|
||||
await cls._send_media_with_action(
|
||||
client,
|
||||
ChatAction.UPLOAD_VOICE,
|
||||
client.send_voice,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
voice=path,
|
||||
**cast(Any, media_payload),
|
||||
**cast(Any, payload),
|
||||
)
|
||||
else:
|
||||
await client.send_voice(voice=path, **cast(Any, payload))
|
||||
@@ -174,17 +162,15 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
"To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'."
|
||||
)
|
||||
if use_media_action:
|
||||
media_payload = dict(payload)
|
||||
if message_thread_id and "message_thread_id" not in media_payload:
|
||||
media_payload["message_thread_id"] = message_thread_id
|
||||
await cls._send_media_with_action(
|
||||
client,
|
||||
ChatAction.UPLOAD_DOCUMENT,
|
||||
client.send_document,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
document=path,
|
||||
caption=caption,
|
||||
**cast(Any, media_payload),
|
||||
**cast(Any, payload),
|
||||
)
|
||||
else:
|
||||
await client.send_document(
|
||||
@@ -292,13 +278,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
caption=i.text or None,
|
||||
use_media_action=False,
|
||||
)
|
||||
elif isinstance(i, Video):
|
||||
path = await i.convert_to_file_path()
|
||||
await client.send_video(
|
||||
video=path,
|
||||
caption=getattr(i, "text", None) or None,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
|
||||
async def send(self, message: MessageChain) -> None:
|
||||
if self.get_message_type() == MessageType.GROUP_MESSAGE:
|
||||
@@ -354,7 +333,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
"chat_id": user_name,
|
||||
}
|
||||
if message_thread_id:
|
||||
payload["message_thread_id"] = message_thread_id
|
||||
payload["reply_to_message_id"] = message_thread_id
|
||||
|
||||
delta = ""
|
||||
current_content = ""
|
||||
@@ -396,6 +375,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
ChatAction.UPLOAD_PHOTO,
|
||||
self.client.send_photo,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
photo=image_path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
@@ -408,6 +388,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
ChatAction.UPLOAD_DOCUMENT,
|
||||
self.client.send_document,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
document=path,
|
||||
filename=name,
|
||||
**cast(Any, payload),
|
||||
@@ -425,17 +406,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
use_media_action=True,
|
||||
)
|
||||
continue
|
||||
elif isinstance(i, Video):
|
||||
path = await i.convert_to_file_path()
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_VIDEO,
|
||||
self.client.send_video,
|
||||
user_name=user_name,
|
||||
video=path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"不支持的消息类型: {type(i)}")
|
||||
continue
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Coroutine
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, cast
|
||||
|
||||
import quart
|
||||
@@ -65,9 +65,7 @@ class WeixinOfficialAccountServer:
|
||||
|
||||
self.event_queue = event_queue
|
||||
|
||||
self.callback: (
|
||||
Callable[[BaseMessage], Coroutine[Any, Any, str | None]] | None
|
||||
) = None
|
||||
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复
|
||||
|
||||
@@ -23,16 +23,12 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
if proxy:
|
||||
logger.info(f"[OpenAI Embedding] 使用代理: {proxy}")
|
||||
http_client = httpx.AsyncClient(proxy=proxy)
|
||||
api_base = provider_config.get("embedding_api_base", "").strip()
|
||||
if not api_base:
|
||||
api_base = "https://api.openai.com/v1"
|
||||
else:
|
||||
api_base = api_base.removesuffix("/")
|
||||
if not api_base.endswith("/v1"):
|
||||
api_base = f"{api_base}/v1"
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=provider_config.get("embedding_api_key"),
|
||||
base_url=api_base,
|
||||
base_url=provider_config.get(
|
||||
"embedding_api_base",
|
||||
"https://api.openai.com/v1",
|
||||
),
|
||||
timeout=int(provider_config.get("timeout", 20)),
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
@@ -105,22 +105,6 @@ class StarHandlerRegistry(Generic[T]):
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
event_type: Literal[EventType.OnPluginLoadedEvent],
|
||||
only_activated=True,
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
event_type: Literal[EventType.OnPluginUnloadedEvent],
|
||||
only_activated=True,
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
|
||||
@@ -388,33 +388,6 @@ class PluginManager:
|
||||
except KeyError:
|
||||
logger.warning(f"模块 {module_name} 未载入")
|
||||
|
||||
def _cleanup_plugin_state(self, dir_name: str) -> None:
|
||||
plugin_root_name = "data.plugins."
|
||||
|
||||
# 清理 sys.modules
|
||||
for key in list(sys.modules.keys()):
|
||||
if key.startswith(f"{plugin_root_name}{dir_name}"):
|
||||
logger.info(f"清除了插件{dir_name}中的{key}模块")
|
||||
del sys.modules[key]
|
||||
|
||||
possible_paths = [
|
||||
f"{plugin_root_name}{dir_name}.main",
|
||||
f"{plugin_root_name}{dir_name}.{dir_name}",
|
||||
]
|
||||
|
||||
# 清理 handlers
|
||||
for path in possible_paths:
|
||||
handlers = star_handlers_registry.get_handlers_by_module_name(path)
|
||||
for handler in handlers:
|
||||
star_handlers_registry.remove(handler)
|
||||
logger.info(f"清理处理器: {handler.handler_name}")
|
||||
|
||||
# 清理工具
|
||||
for tool in list(llm_tools.func_list):
|
||||
if tool.handler_module_path in possible_paths:
|
||||
llm_tools.func_list.remove(tool)
|
||||
logger.info(f"清理工具: {tool.name}")
|
||||
|
||||
async def reload_failed_plugin(self, dir_name):
|
||||
"""
|
||||
重新加载未注册(加载失败)的插件
|
||||
@@ -425,21 +398,17 @@ class PluginManager:
|
||||
- success (bool): 重载是否成功
|
||||
- error_message (str|None): 错误信息,成功时为 None
|
||||
"""
|
||||
|
||||
async with self._pm_lock:
|
||||
if dir_name not in self.failed_plugin_dict:
|
||||
return False, "插件不存在于失败列表中"
|
||||
|
||||
self._cleanup_plugin_state(dir_name)
|
||||
|
||||
success, error = await self.load(specified_dir_name=dir_name)
|
||||
if success:
|
||||
self.failed_plugin_dict.pop(dir_name, None)
|
||||
if not self.failed_plugin_dict:
|
||||
self.failed_plugin_info = ""
|
||||
return success, None
|
||||
else:
|
||||
return False, error
|
||||
if dir_name in self.failed_plugin_dict:
|
||||
success, error = await self.load(specified_dir_name=dir_name)
|
||||
if success:
|
||||
self.failed_plugin_dict.pop(dir_name, None)
|
||||
if not self.failed_plugin_dict:
|
||||
self.failed_plugin_info = ""
|
||||
return success, None
|
||||
else:
|
||||
return False, error
|
||||
return False, "插件不存在于失败列表中"
|
||||
|
||||
async def reload(self, specified_plugin_name=None):
|
||||
"""重新加载插件
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.dataclasses import dataclass
|
||||
@@ -9,14 +8,6 @@ from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
||||
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"
|
||||
@@ -128,15 +119,9 @@ class DeleteCronJobTool(FunctionTool[AstrAgentContext]):
|
||||
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}."
|
||||
|
||||
@@ -163,13 +148,8 @@ class ListCronJobsTool(FunctionTool[AstrAgentContext]):
|
||||
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
|
||||
]
|
||||
jobs = await cron_mgr.list_jobs(job_type)
|
||||
if not jobs:
|
||||
return "No cron jobs found."
|
||||
lines = []
|
||||
|
||||
@@ -19,7 +19,7 @@ from astrbot.core.message.components import (
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
|
||||
|
||||
from .image_refs import looks_like_image_file_name
|
||||
from .image_refs import looks_like_image_file_name, normalize_file_like_url
|
||||
from .settings import SETTINGS, QuotedMessageParserSettings
|
||||
|
||||
_FORWARD_PLACEHOLDER_PATTERN = re.compile(
|
||||
@@ -296,11 +296,11 @@ def _parse_onebot_segments(
|
||||
or "file"
|
||||
)
|
||||
text_parts.append(f"[File:{file_name}]")
|
||||
candidate_url = seg_data.get("url", "")
|
||||
candidate_url = seg_data.get("url")
|
||||
if (
|
||||
isinstance(candidate_url, str)
|
||||
and candidate_url.strip()
|
||||
and looks_like_image_file_name(candidate_url)
|
||||
and looks_like_image_file_name(normalize_file_like_url(candidate_url))
|
||||
):
|
||||
image_refs.append(candidate_url.strip())
|
||||
candidate_file = seg_data.get("file")
|
||||
@@ -308,7 +308,11 @@ def _parse_onebot_segments(
|
||||
isinstance(candidate_file, str)
|
||||
and candidate_file.strip()
|
||||
and looks_like_image_file_name(
|
||||
seg_data.get("name") or seg_data.get("file_name") or candidate_file
|
||||
normalize_file_like_url(
|
||||
seg_data.get("name")
|
||||
or seg_data.get("file_name")
|
||||
or candidate_file
|
||||
)
|
||||
)
|
||||
):
|
||||
image_refs.append(candidate_file.strip())
|
||||
@@ -364,9 +368,7 @@ def _extract_text_forward_ids_and_images_from_forward_nodes(
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
sender = node.get("sender")
|
||||
if not isinstance(sender, dict):
|
||||
sender = {}
|
||||
sender = node.get("sender") if isinstance(node.get("sender"), dict) else {}
|
||||
sender_name = (
|
||||
sender.get("nickname")
|
||||
or sender.get("card")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable
|
||||
from typing import Any, Protocol
|
||||
from typing import Any
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
@@ -18,10 +17,6 @@ def _unwrap_action_response(ret: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return ret
|
||||
|
||||
|
||||
class CallAction(Protocol):
|
||||
def __call__(self, action: str, **params: Any) -> Awaitable[Any] | Any: ...
|
||||
|
||||
|
||||
class OneBotClient:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -32,7 +27,7 @@ class OneBotClient:
|
||||
self._settings = settings
|
||||
|
||||
@staticmethod
|
||||
def _resolve_call_action(event: AstrMessageEvent) -> CallAction | None:
|
||||
def _resolve_call_action(event: AstrMessageEvent):
|
||||
bot = getattr(event, "bot", None)
|
||||
api = getattr(bot, "api", None)
|
||||
call_action = getattr(api, "call_action", None)
|
||||
|
||||
@@ -754,22 +754,6 @@ class ConfigRoute(Route):
|
||||
if not provider_type:
|
||||
return Response().error("provider_config 缺少 type 字段").__dict__
|
||||
|
||||
# 首次添加某类提供商时,provider_cls_map 可能尚未注册该适配器
|
||||
if provider_type not in provider_cls_map:
|
||||
try:
|
||||
self.core_lifecycle.provider_manager.dynamic_import_provider(
|
||||
provider_type,
|
||||
)
|
||||
except ImportError:
|
||||
logger.error(traceback.format_exc())
|
||||
return (
|
||||
Response()
|
||||
.error(
|
||||
"提供商适配器加载失败,请检查提供商类型配置或查看服务端日志"
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
# 获取对应的 provider 类
|
||||
if provider_type not in provider_cls_map:
|
||||
return (
|
||||
@@ -795,7 +779,7 @@ class ConfigRoute(Route):
|
||||
if inspect.iscoroutinefunction(init_fn):
|
||||
await init_fn()
|
||||
|
||||
# 通过实际请求验证当前 embedding_dimensions 是否可用
|
||||
# 获取嵌入向量维度
|
||||
vec = await inst.get_embedding("echo")
|
||||
dim = len(vec)
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ class ConversationRoute(Route):
|
||||
user_id = data.get("user_id")
|
||||
cid = data.get("cid")
|
||||
title = data.get("title")
|
||||
persona_id = data.get("persona_id", "")
|
||||
|
||||
if not user_id or not cid:
|
||||
return Response().error("缺少必要参数: user_id 和 cid").__dict__
|
||||
@@ -157,9 +158,6 @@ class ConversationRoute(Route):
|
||||
)
|
||||
if not conversation:
|
||||
return Response().error("对话不存在").__dict__
|
||||
|
||||
persona_id = data.get("persona_id", conversation.persona_id)
|
||||
|
||||
if title is not None or persona_id is not None:
|
||||
await self.conv_mgr.update_conversation(
|
||||
unified_msg_origin=user_id,
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增桌面端通用更新桥接能力,便于接入客户端内更新流程 ([#5424](https://github.com/AstrBotDevs/AstrBot/issues/5424))。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复新增平台对话框中 Line 适配器未显示的问题。
|
||||
- 修复 Telegram 无法发送 Video 的问题 ([#5430](https://github.com/AstrBotDevs/AstrBot/issues/5430))。
|
||||
- 修复创建 embedding provider 时无法自动识别向量维度的问题 ([#5442](https://github.com/AstrBotDevs/AstrBot/issues/5442))。
|
||||
- 修复 QQ 官方平台发送媒体消息时 markdown 字段未清理的问题 ([#5445](https://github.com/AstrBotDevs/AstrBot/issues/5445))。
|
||||
- 修复上下文管理策略 -> 上下文截断时 tool call / response 配对丢失的问题 ([#5417](https://github.com/AstrBotDevs/AstrBot/issues/5417))。
|
||||
- 修复会话更新时 `persona_id` 被覆盖的问题,并增强 persona 解析逻辑。
|
||||
- 修复 WebUI 中 GitHub 代理地址显示异常的问题 ([#5438](https://github.com/AstrBotDevs/AstrBot/issues/5438))。
|
||||
- 修复设置页新建开发者 API Key 后复制失败的问题 ([#5439](https://github.com/AstrBotDevs/AstrBot/issues/5439))。
|
||||
- 修复 Telegram 语音消息格式与 OpenAI STT 兼容性问题(使用 OGG) ([#5389](https://github.com/AstrBotDevs/AstrBot/issues/5389))。
|
||||
|
||||
### 优化
|
||||
|
||||
- 优化知识库检索流程,改为批量查询元数据,修复 N+1 查询性能问题 ([#5463](https://github.com/AstrBotDevs/AstrBot/issues/5463))。
|
||||
- 优化 Cron 未来任务执行的会话隔离能力,提升并发稳定性。
|
||||
- 优化 WebUI 插件页的交互。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
|
||||
- Added `useExtensionPage` composable for unified plugin extension page state management.
|
||||
- Added a generic desktop app updater bridge to support in-app update workflows ([#5424](https://github.com/AstrBotDevs/AstrBot/issues/5424)).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed the Line adapter not appearing in the "Add Platform" dialog.
|
||||
- Fixed Telegram video sending issues ([#5430](https://github.com/AstrBotDevs/AstrBot/issues/5430)).
|
||||
- Fixed Pyright static type checking errors ([#5437](https://github.com/AstrBotDevs/AstrBot/issues/5437)).
|
||||
- Fixed embedding dimension auto-detection when creating embedding providers ([#5442](https://github.com/AstrBotDevs/AstrBot/issues/5442)).
|
||||
- Fixed stale markdown fields when sending media messages via QQ Official Platform ([#5445](https://github.com/AstrBotDevs/AstrBot/issues/5445)).
|
||||
- Fixed tool call/response pairing loss during context truncation ([#5417](https://github.com/AstrBotDevs/AstrBot/issues/5417)).
|
||||
- Fixed `persona_id` being overwritten during conversation updates and improved persona resolution logic.
|
||||
- Fixed incorrect GitHub proxy display in WebUI ([#5438](https://github.com/AstrBotDevs/AstrBot/issues/5438)).
|
||||
- Fixed API key copy failure after creating a new key in settings ([#5439](https://github.com/AstrBotDevs/AstrBot/issues/5439)).
|
||||
- Fixed Telegram voice format compatibility with OpenAI STT by using OGG ([#5389](https://github.com/AstrBotDevs/AstrBot/issues/5389)).
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improved knowledge base retrieval by batching metadata queries to eliminate the N+1 query pattern ([#5463](https://github.com/AstrBotDevs/AstrBot/issues/5463)).
|
||||
- Improved session isolation for future cron tasks to increase stability under concurrency.
|
||||
- Improved WebUI plugin page interactions.
|
||||
@@ -34,7 +34,6 @@ const platformDisplayList = computed(() =>
|
||||
const handleInstall = (plugin) => {
|
||||
emit("install", plugin);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -124,7 +123,6 @@ const handleInstall = (plugin) => {
|
||||
v-if="plugin?.social_link"
|
||||
:href="plugin.social_link"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
class="text-subtitle-2 font-weight-medium"
|
||||
style="
|
||||
text-decoration: none;
|
||||
@@ -215,10 +213,7 @@ const handleInstall = (plugin) => {
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions
|
||||
style="gap: 6px; padding: 8px 12px; padding-top: 0"
|
||||
@click.stop
|
||||
>
|
||||
<v-card-actions style="gap: 6px; padding: 8px 12px; padding-top: 0">
|
||||
<v-chip
|
||||
v-for="tag in plugin.tags?.slice(0, 2)"
|
||||
:key="tag"
|
||||
@@ -253,24 +248,22 @@ const handleInstall = (plugin) => {
|
||||
<v-btn
|
||||
v-if="plugin?.repo"
|
||||
color="secondary"
|
||||
size="small"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="market-action-btn"
|
||||
:href="plugin.repo"
|
||||
target="_blank"
|
||||
style="height: 32px"
|
||||
style="height: 24px"
|
||||
>
|
||||
<v-icon icon="mdi-github" start size="small"></v-icon>
|
||||
<v-icon icon="mdi-github" start size="x-small"></v-icon>
|
||||
{{ tm("buttons.viewRepo") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!plugin?.installed"
|
||||
color="primary"
|
||||
size="small"
|
||||
size="x-small"
|
||||
@click="handleInstall(plugin)"
|
||||
variant="flat"
|
||||
class="market-action-btn"
|
||||
style="height: 32px"
|
||||
style="height: 24px"
|
||||
>
|
||||
{{ tm("buttons.install") }}
|
||||
</v-btn>
|
||||
@@ -313,9 +306,4 @@ const handleInstall = (plugin) => {
|
||||
.plugin-description::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(var(--v-theme-primary-rgb), 0.6);
|
||||
}
|
||||
|
||||
.market-action-btn {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,40 +48,6 @@ const filteredIterable = computed(() => {
|
||||
return rest
|
||||
})
|
||||
|
||||
const providerHint = computed(() => {
|
||||
const hint = props.iterable?.hint
|
||||
if (typeof hint !== 'string' || !hint) return ''
|
||||
|
||||
if (
|
||||
hint === 'provider_group.provider.openai_embedding.hint'
|
||||
|| hint === 'provider_group.provider.gemini_embedding.hint'
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return hint
|
||||
})
|
||||
|
||||
const getItemHint = (itemKey, itemMeta) => {
|
||||
if (itemMeta?.hint) return itemMeta.hint
|
||||
|
||||
if (itemKey !== 'embedding_api_base') return ''
|
||||
|
||||
const providerType = props.iterable?.type
|
||||
if (providerType === 'openai_embedding') {
|
||||
return getRaw('provider_group.provider.openai_embedding.hint')
|
||||
? 'provider_group.provider.openai_embedding.hint'
|
||||
: ''
|
||||
}
|
||||
if (providerType === 'gemini_embedding') {
|
||||
return getRaw('provider_group.provider.gemini_embedding.hint')
|
||||
? 'provider_group.provider.gemini_embedding.hint'
|
||||
: ''
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const dialog = ref(false)
|
||||
const currentEditingKey = ref('')
|
||||
const currentEditingLanguage = ref('json')
|
||||
@@ -187,14 +153,14 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
<div v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template" class="object-config">
|
||||
<!-- Provider-level hint -->
|
||||
<v-alert
|
||||
v-if="providerHint"
|
||||
v-if="iterable.hint && !isEditing"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
border="start"
|
||||
density="compact"
|
||||
>
|
||||
{{ translateIfKey(providerHint) }}
|
||||
{{ iterable.hint }}
|
||||
</v-alert>
|
||||
|
||||
<div v-for="(val, key, index) in filteredIterable" :key="key" class="config-item">
|
||||
@@ -252,9 +218,9 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="property-hint">
|
||||
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && getItemHint(key, metadata[metadataKey].items[key])"
|
||||
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
|
||||
class="important-hint">‼️</span>
|
||||
{{ translateIfKey(getItemHint(key, metadata[metadataKey].items[key])) }}
|
||||
{{ translateIfKey(metadata[metadataKey].items[key]?.hint) }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, inject, watch } from "vue";
|
||||
import { ref, computed, inject } from "vue";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { getPlatformDisplayName, getPlatformIcon } from "@/utils/platformUtils";
|
||||
import UninstallConfirmDialog from "./UninstallConfirmDialog.vue";
|
||||
import PluginPlatformChip from "./PluginPlatformChip.vue";
|
||||
import StyledMenu from "./StyledMenu.vue";
|
||||
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
|
||||
|
||||
const props = defineProps({
|
||||
extension: {
|
||||
@@ -61,25 +59,6 @@ const astrbotVersionRequirement = computed(() => {
|
||||
: "";
|
||||
});
|
||||
|
||||
const logoLoadFailed = ref(false);
|
||||
|
||||
const logoSrc = computed(() => {
|
||||
const logo = props.extension?.logo;
|
||||
if (logoLoadFailed.value) {
|
||||
return defaultPluginIcon;
|
||||
}
|
||||
return typeof logo === "string" && logo.trim().length
|
||||
? logo
|
||||
: defaultPluginIcon;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.extension?.logo,
|
||||
() => {
|
||||
logoLoadFailed.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
// 操作函数
|
||||
const configure = () => {
|
||||
emit("configure", props.extension);
|
||||
@@ -125,7 +104,6 @@ const viewReadme = () => {
|
||||
const viewChangelog = () => {
|
||||
emit("view-changelog", props.extension);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -151,292 +129,249 @@ const viewChangelog = () => {
|
||||
style="
|
||||
padding: 16px;
|
||||
padding-bottom: 0px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<div style="overflow-x: auto; width: 100%">
|
||||
<div style="width: 100%; margin-bottom: 24px">
|
||||
<div class="extension-title-row">
|
||||
<p
|
||||
class="text-h3 font-weight-black extension-title"
|
||||
:class="{ 'text-h4': $vuetify.display.xs }"
|
||||
>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
:text="
|
||||
extension.display_name?.length &&
|
||||
extension.display_name !== extension.name
|
||||
? `${extension.display_name} (${extension.name})`
|
||||
: extension.name
|
||||
"
|
||||
<div v-if="extension?.logo">
|
||||
<img :src="extension.logo" :alt="extension.name" cover width="100" />
|
||||
</div>
|
||||
|
||||
<div style="overflow-x: auto">
|
||||
<!-- Top-right three-dot menu -->
|
||||
<div style="position: absolute; right: 8px; top: 8px; z-index: 5">
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
aria-label="more"
|
||||
v-if="extension?.repo"
|
||||
:href="extension?.repo"
|
||||
target="_blank"
|
||||
>
|
||||
<template v-slot:activator="{ props: titleTooltipProps }">
|
||||
<span v-bind="titleTooltipProps" class="extension-title__text">{{
|
||||
extension.display_name?.length
|
||||
? extension.display_name
|
||||
: extension.name
|
||||
}}</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
v-if="extension?.has_update && !marketMode"
|
||||
>
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="warning"
|
||||
class="ml-2"
|
||||
icon="mdi-update"
|
||||
size="small"
|
||||
></v-icon>
|
||||
</template>
|
||||
<span
|
||||
>{{ tm("card.status.hasUpdate") }}:
|
||||
{{ extension.online_version }}</span
|
||||
<v-icon icon="mdi-github"></v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-bind="menuProps" icon variant="text" aria-label="more">
|
||||
<v-icon icon="mdi-dots-vertical"></v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item @click="viewReadme">
|
||||
<v-list-item-title
|
||||
>📄 {{ tm("buttons.viewDocs") }}</v-list-item-title
|
||||
>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
v-if="!extension.activated && !marketMode"
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-if="!marketMode" @click="viewChangelog">
|
||||
<v-list-item-title
|
||||
>📝 {{ tm("pluginChangelog.menuTitle") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
v-if="marketMode && !extension?.installed"
|
||||
@click="installExtension"
|
||||
>
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="error"
|
||||
class="ml-2"
|
||||
icon="mdi-cancel"
|
||||
size="small"
|
||||
></v-icon>
|
||||
</template>
|
||||
<span>{{ tm("card.status.disabled") }}</span>
|
||||
</v-tooltip>
|
||||
</p>
|
||||
<v-list-item-title>
|
||||
{{ tm("buttons.install") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<template v-if="!marketMode">
|
||||
<v-tooltip location="left">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<div v-bind="tooltipProps" class="extension-switch-wrap" @click.stop>
|
||||
<v-switch
|
||||
:model-value="extension.activated"
|
||||
color="success"
|
||||
density="compact"
|
||||
hide-details
|
||||
inset
|
||||
@update:model-value="toggleActivation"
|
||||
></v-switch>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{
|
||||
extension.activated ? tm("buttons.disable") : tm("buttons.enable")
|
||||
}}</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="extension-market-menu-wrap">
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
aria-label="more"
|
||||
v-if="extension?.repo"
|
||||
:href="extension?.repo"
|
||||
target="_blank"
|
||||
>
|
||||
<v-icon icon="mdi-github"></v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-bind="menuProps" icon variant="text" aria-label="more">
|
||||
<v-icon icon="mdi-dots-vertical"></v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list-item v-if="marketMode && extension?.installed">
|
||||
<v-list-item-title class="text--disabled">{{
|
||||
tm("status.installed")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list>
|
||||
<v-list-item @click="viewReadme">
|
||||
<v-list-item-title
|
||||
>📄 {{ tm("buttons.viewDocs") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
<!-- Divider between market actions and plugin actions -->
|
||||
<v-divider v-if="!marketMode" />
|
||||
|
||||
<v-list-item
|
||||
v-if="marketMode && !extension?.installed"
|
||||
@click="installExtension"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ tm("buttons.install") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
<template v-if="!marketMode">
|
||||
<v-list-item @click="configure">
|
||||
<v-list-item-title>
|
||||
{{ tm("card.actions.pluginConfig") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-if="marketMode && extension?.installed">
|
||||
<v-list-item-title class="text--disabled">{{
|
||||
tm("status.installed")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
<v-list-item @click="uninstallExtension">
|
||||
<v-list-item-title class="text-error">{{
|
||||
tm("card.actions.uninstallPlugin")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="reloadExtension">
|
||||
<v-list-item-title>{{
|
||||
tm("card.actions.reloadPlugin")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="toggleActivation">
|
||||
<v-list-item-title>
|
||||
{{
|
||||
extension.activated
|
||||
? tm("buttons.disable")
|
||||
: tm("buttons.enable")
|
||||
}}{{ tm("card.actions.togglePlugin") }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="viewHandlers">
|
||||
<v-list-item-title
|
||||
>{{ tm("card.actions.viewHandlers") }} ({{
|
||||
extension.handlers.length
|
||||
}})</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="updateExtension">
|
||||
<v-list-item-title>
|
||||
{{
|
||||
extension.has_update
|
||||
? tm("card.actions.updateTo") +
|
||||
" " +
|
||||
extension.online_version
|
||||
: tm("card.actions.reinstall")
|
||||
}}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<div style="width: 100%; margin-bottom: 24px">
|
||||
<!-- 最多一行 -->
|
||||
<div
|
||||
class="text-caption"
|
||||
style="
|
||||
color: gray;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 84px;
|
||||
"
|
||||
>
|
||||
{{ extension.author }} / {{ extension.name }}
|
||||
</div>
|
||||
<p
|
||||
class="text-h3 font-weight-black extension-title"
|
||||
:class="{ 'text-h4': $vuetify.display.xs }"
|
||||
>
|
||||
<span class="extension-title__text">{{
|
||||
extension.display_name?.length
|
||||
? extension.display_name
|
||||
: extension.name
|
||||
}}</span>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
v-if="extension?.has_update && !marketMode"
|
||||
>
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="warning"
|
||||
class="ml-2"
|
||||
icon="mdi-update"
|
||||
size="small"
|
||||
></v-icon>
|
||||
</template>
|
||||
<span
|
||||
>{{ tm("card.status.hasUpdate") }}:
|
||||
{{ extension.online_version }}</span
|
||||
>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
v-if="!extension.activated && !marketMode"
|
||||
>
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="error"
|
||||
class="ml-2"
|
||||
icon="mdi-cancel"
|
||||
size="small"
|
||||
></v-icon>
|
||||
</template>
|
||||
<span>{{ tm("card.status.disabled") }}</span>
|
||||
</v-tooltip>
|
||||
</p>
|
||||
|
||||
<div class="mt-1 d-flex flex-wrap">
|
||||
<v-chip color="primary" label size="small">
|
||||
<v-icon icon="mdi-source-branch" start></v-icon>
|
||||
{{ extension.version }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="extension?.has_update"
|
||||
color="warning"
|
||||
label
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
<v-icon icon="mdi-arrow-up-bold" start></v-icon>
|
||||
{{ extension.online_version }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
color="primary"
|
||||
label
|
||||
size="small"
|
||||
class="ml-2"
|
||||
v-if="extension.handlers?.length"
|
||||
@click="viewHandlers"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<v-icon icon="mdi-cogs" start></v-icon>
|
||||
{{ extension.handlers?.length
|
||||
}}{{ tm("card.status.handlersCount") }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-for="tag in extension.tags"
|
||||
:key="tag"
|
||||
:color="tag === 'danger' ? 'error' : 'primary'"
|
||||
label
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ tag === "danger" ? tm("tags.danger") : tag }}
|
||||
</v-chip>
|
||||
<PluginPlatformChip
|
||||
:platforms="supportPlatforms"
|
||||
class="ml-2"
|
||||
/>
|
||||
<v-chip
|
||||
v-if="astrbotVersionRequirement"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
label
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
AstrBot: {{ astrbotVersionRequirement }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="extension-content-row mt-2">
|
||||
<div class="extension-image-container">
|
||||
<img
|
||||
:src="logoSrc"
|
||||
:alt="extension.name"
|
||||
class="extension-logo"
|
||||
@error="logoLoadFailed = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="extension-meta-group">
|
||||
<div class="extension-chip-group d-flex flex-wrap">
|
||||
<v-chip color="primary" label size="small">
|
||||
<v-icon icon="mdi-source-branch" start></v-icon>
|
||||
{{ extension.version }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="extension?.has_update"
|
||||
color="warning"
|
||||
label
|
||||
size="small"
|
||||
>
|
||||
<v-icon icon="mdi-arrow-up-bold" start></v-icon>
|
||||
{{ extension.online_version }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="extension.handlers?.length"
|
||||
color="primary"
|
||||
label
|
||||
size="small"
|
||||
@click="viewHandlers"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<v-icon icon="mdi-cogs" start></v-icon>
|
||||
{{ extension.handlers?.length
|
||||
}}{{ tm("card.status.handlersCount") }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-for="tag in extension.tags"
|
||||
:key="tag"
|
||||
:color="tag === 'danger' ? 'error' : 'primary'"
|
||||
label
|
||||
size="small"
|
||||
>
|
||||
{{ tag === "danger" ? tm("tags.danger") : tag }}
|
||||
</v-chip>
|
||||
<PluginPlatformChip :platforms="supportPlatforms" />
|
||||
<v-chip
|
||||
v-if="astrbotVersionRequirement"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
label
|
||||
size="small"
|
||||
>
|
||||
AstrBot: {{ astrbotVersionRequirement }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="extension-desc"
|
||||
:class="{ 'text-caption': $vuetify.display.xs }"
|
||||
>
|
||||
{{ extension.desc }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2"
|
||||
:class="{ 'text-caption': $vuetify.display.xs }"
|
||||
style="overflow-y: auto; height: 70px; font-size: 90%"
|
||||
>
|
||||
{{ extension.desc }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="extension-actions" @click.stop>
|
||||
<template v-if="!marketMode">
|
||||
<v-spacer></v-spacer>
|
||||
<v-tooltip location="top" :text="tm('buttons.viewDocs')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
v-bind="actionProps"
|
||||
icon="mdi-book-open-page-variant"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
@click="viewReadme"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" :text="tm('card.actions.pluginConfig')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
v-bind="actionProps"
|
||||
icon="mdi-cog"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click="configure"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip v-if="extension?.repo" location="top" :text="tm('buttons.viewRepo')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
v-bind="actionProps"
|
||||
icon="mdi-github"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
:href="extension.repo"
|
||||
target="_blank"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" :text="tm('card.actions.reloadPlugin')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
v-bind="actionProps"
|
||||
icon="mdi-refresh"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click="reloadExtension"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<StyledMenu location="top end" offset="8">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon="mdi-dots-horizontal"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
></v-btn>
|
||||
</template>
|
||||
|
||||
<v-list-item class="styled-menu-item" prepend-icon="mdi-information" @click="viewHandlers">
|
||||
<v-list-item-title>{{ tm("buttons.viewInfo") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="styled-menu-item" prepend-icon="mdi-update" @click="updateExtension">
|
||||
<v-list-item-title>{{
|
||||
extension.has_update
|
||||
? tm("card.actions.updateTo") + " " + extension.online_version
|
||||
: tm("card.actions.reinstall")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="styled-menu-item" prepend-icon="mdi-delete" @click="uninstallExtension">
|
||||
<v-list-item-title class="text-error">{{ tm("card.actions.uninstallPlugin") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</StyledMenu>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-btn color="primary" size="small" @click="viewReadme">
|
||||
{{ tm("buttons.viewDocs") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card-actions class="extension-actions">
|
||||
<v-btn color="primary" size="small" @click="viewReadme">
|
||||
{{ tm("buttons.viewDocs") }}
|
||||
</v-btn>
|
||||
<v-btn v-if="!marketMode" color="primary" size="small" @click="configure">
|
||||
{{ tm("card.actions.pluginConfig") }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
@@ -450,52 +385,13 @@ const viewChangelog = () => {
|
||||
<style scoped>
|
||||
.extension-image-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extension-logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.extension-content-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.extension-meta-group {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.extension-chip-group {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.extension-desc {
|
||||
margin-top: 8px;
|
||||
font-size: 90%;
|
||||
overflow-y: auto;
|
||||
height: 70px;
|
||||
align-items: center;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.extension-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.extension-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.extension-title__text {
|
||||
@@ -503,38 +399,17 @@ const viewChangelog = () => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.extension-switch-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extension-switch-wrap :deep(.v-switch) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.extension-market-menu-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.extension-content-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.extension-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
.extension-image-container {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.extension-actions {
|
||||
margin-top: auto;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<v-expand-transition>
|
||||
<div v-if="radioValue === '1'" style="margin-left: 16px;">
|
||||
<v-radio-group v-model="githubProxyRadioControl" class="mt-2" hide-details="true">
|
||||
<v-radio color="success" v-for="(proxy, idx) in githubProxies" :key="proxy" :value="String(idx)">
|
||||
<v-radio color="success" v-for="(proxy, idx) in githubProxies" :key="proxy" :value="idx">
|
||||
<template v-slot:label>
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2">{{ proxy }}</span>
|
||||
@@ -37,7 +37,7 @@
|
||||
</template>
|
||||
</v-radio>
|
||||
<v-radio color="primary" value="-1" :label="tm('network.proxySelector.custom')">
|
||||
<template v-slot:label v-if="String(githubProxyRadioControl) === '-1'">
|
||||
<template v-slot:label v-if="githubProxyRadioControl === '-1'">
|
||||
<v-text-field density="compact" v-model="selectedGitHubProxy" variant="outlined"
|
||||
style="width: 100vw;" :placeholder="tm('network.proxySelector.custom')" hide-details="true">
|
||||
</v-text-field>
|
||||
@@ -72,21 +72,9 @@ export default {
|
||||
loadingTestingConnection: false,
|
||||
testingProxies: {},
|
||||
proxyStatus: {},
|
||||
initializing: true,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getProxyByControl(control) {
|
||||
const normalizedControl = String(control);
|
||||
if (normalizedControl === "-1") {
|
||||
return "";
|
||||
}
|
||||
const index = Number.parseInt(normalizedControl, 10);
|
||||
if (Number.isNaN(index)) {
|
||||
return "";
|
||||
}
|
||||
return this.githubProxies[index] || "";
|
||||
},
|
||||
async testSingleProxy(idx) {
|
||||
this.testingProxies[idx] = true;
|
||||
|
||||
@@ -130,60 +118,42 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initializing = true;
|
||||
|
||||
const savedProxy = localStorage.getItem('selectedGitHubProxy') || "";
|
||||
const savedRadio = localStorage.getItem('githubProxyRadioValue') || "0";
|
||||
const savedControl = String(localStorage.getItem('githubProxyRadioControl') || "0");
|
||||
|
||||
this.radioValue = savedRadio;
|
||||
this.githubProxyRadioControl = savedControl;
|
||||
|
||||
if (savedRadio === "1") {
|
||||
if (savedControl !== "-1") {
|
||||
this.selectedGitHubProxy = this.getProxyByControl(savedControl);
|
||||
} else {
|
||||
this.selectedGitHubProxy = savedProxy;
|
||||
this.selectedGitHubProxy = localStorage.getItem('selectedGitHubProxy') || "";
|
||||
this.radioValue = localStorage.getItem('githubProxyRadioValue') || "0";
|
||||
this.githubProxyRadioControl = localStorage.getItem('githubProxyRadioControl') || "0";
|
||||
if (this.radioValue === "1") {
|
||||
if (this.githubProxyRadioControl !== "-1") {
|
||||
this.selectedGitHubProxy = this.githubProxies[this.githubProxyRadioControl] || "";
|
||||
}
|
||||
} else {
|
||||
this.selectedGitHubProxy = "";
|
||||
}
|
||||
|
||||
this.initializing = false;
|
||||
},
|
||||
watch: {
|
||||
selectedGitHubProxy: function (newVal, oldVal) {
|
||||
if (this.initializing) {
|
||||
return;
|
||||
}
|
||||
if (!newVal) {
|
||||
newVal = ""
|
||||
}
|
||||
localStorage.setItem('selectedGitHubProxy', newVal);
|
||||
},
|
||||
radioValue: function (newVal) {
|
||||
if (this.initializing) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('githubProxyRadioValue', newVal);
|
||||
if (String(newVal) === "0") {
|
||||
if (newVal === "0") {
|
||||
this.selectedGitHubProxy = "";
|
||||
} else if (String(this.githubProxyRadioControl) !== "-1") {
|
||||
this.selectedGitHubProxy = this.getProxyByControl(this.githubProxyRadioControl);
|
||||
} else if (this.githubProxyRadioControl !== "-1") {
|
||||
this.selectedGitHubProxy = this.githubProxies[this.githubProxyRadioControl] || "";
|
||||
}
|
||||
},
|
||||
githubProxyRadioControl: function (newVal) {
|
||||
if (this.initializing) {
|
||||
return;
|
||||
}
|
||||
const normalizedVal = String(newVal);
|
||||
localStorage.setItem('githubProxyRadioControl', normalizedVal);
|
||||
if (String(this.radioValue) !== "1") {
|
||||
localStorage.setItem('githubProxyRadioControl', newVal);
|
||||
if (this.radioValue !== "1") {
|
||||
this.selectedGitHubProxy = "";
|
||||
return;
|
||||
}
|
||||
if (normalizedVal !== "-1") {
|
||||
this.selectedGitHubProxy = this.getProxyByControl(normalizedVal);
|
||||
if (newVal !== "-1") {
|
||||
this.selectedGitHubProxy = this.githubProxies[newVal] || "";
|
||||
} else {
|
||||
this.selectedGitHubProxy = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
"provider_settings": {
|
||||
"computer_use_runtime": {
|
||||
"description": "Computer Use Runtime",
|
||||
"hint": "sandbox means running in a remote sandbox environment, local means running directly on the local machine, local_sandboxed means local execution with OS-level sandboxing (bwrap/seatbelt), and none means disabling Computer Use. If skills are uploaded, choosing none will cause them to not be usable by the Agent."
|
||||
"hint": "sandbox means running in a sandbox environment, local means running in a local environment, none means disabling Computer Use. If skills are uploaded, choosing none will cause them to not be usable by the Agent."
|
||||
},
|
||||
"computer_use_require_admin": {
|
||||
"description": "Require AstrBot Admin Permission",
|
||||
@@ -1086,12 +1086,6 @@
|
||||
"embedding_api_base": {
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"openai_embedding": {
|
||||
"hint": "OpenAI Embedding automatically appends /v1 at request time."
|
||||
},
|
||||
"gemini_embedding": {
|
||||
"hint": "Gemini Embedding does not require manually adding /v1beta."
|
||||
},
|
||||
"volcengine_cluster": {
|
||||
"description": "Volcengine cluster",
|
||||
"hint": "For voice cloning models, choose volcano_icl or volcano_icl_concurr; default is volcano_tts."
|
||||
@@ -1319,10 +1313,6 @@
|
||||
"api_base": {
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"proxy": {
|
||||
"description": "Proxy address",
|
||||
"hint": "HTTP/HTTPS proxy URL, e.g. http://127.0.0.1:7890. Applies only to this provider's API requests and does not affect Docker internal networking."
|
||||
},
|
||||
"model": {
|
||||
"description": "Model ID",
|
||||
"hint": "Model name, e.g., gpt-4o-mini, deepseek-chat."
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
"handlersOperation": "Manage Handlers",
|
||||
"market": "AstrBot Plugin Market"
|
||||
},
|
||||
"titles": {
|
||||
"installedAstrBotPlugins": "Installed AstrBot Plugins"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search extensions...",
|
||||
"marketPlaceholder": "Search market extensions..."
|
||||
@@ -225,7 +222,7 @@
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"deleteFailed": "Delete failed",
|
||||
"runtimeNoneWarning": "Computer Use runtime is set to None; Skills may not run correctly because no runtime is enabled.",
|
||||
"runtimeHint": "Set the Computer Use runtime to Local, Local Sandboxed, or Sandbox in settings so AstrBot can use your Skills."
|
||||
"runtimeHint": "Set the Computer Use runtime to Local or Sandbox in settings so AstrBot can use your Skills."
|
||||
},
|
||||
"card": {
|
||||
"actions": {
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
"provider_settings": {
|
||||
"computer_use_runtime": {
|
||||
"description": "运行环境",
|
||||
"hint": "sandbox 代表在远程沙箱环境中运行, local 代表在本地直接运行, local_sandboxed 代表本地运行但使用系统沙箱(bwrap/seatbelt)增强隔离, none 代表不启用。如果上传了 skills,选择 none 会导致其无法被 Agent 正常使用。"
|
||||
"hint": "sandbox 代表在沙箱环境中运行, local 代表在本地环境中运行, none 代表不启用。如果上传了 skills,选择 none 会导致其无法被 Agent 正常使用。"
|
||||
},
|
||||
"computer_use_require_admin": {
|
||||
"description": "需要 AstrBot 管理员权限",
|
||||
@@ -1089,12 +1089,6 @@
|
||||
"embedding_api_base": {
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"openai_embedding": {
|
||||
"hint": "OpenAI Embedding 会在请求时自动补上 /v1。"
|
||||
},
|
||||
"gemini_embedding": {
|
||||
"hint": "Gemini Embedding 无需手动添加 /v1beta。"
|
||||
},
|
||||
"volcengine_cluster": {
|
||||
"description": "火山引擎集群",
|
||||
"hint": "若使用语音复刻大模型,可选volcano_icl或volcano_icl_concurr,默认使用volcano_tts"
|
||||
@@ -1322,10 +1316,6 @@
|
||||
"api_base": {
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"proxy": {
|
||||
"description": "代理地址",
|
||||
"hint": "HTTP/HTTPS 代理地址,格式如 http://127.0.0.1:7890。仅对该提供商的 API 请求生效,不影响 Docker 内网通信。"
|
||||
},
|
||||
"model": {
|
||||
"description": "模型 ID",
|
||||
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。"
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
"skills": "Skills",
|
||||
"handlersOperation": "管理行为"
|
||||
},
|
||||
"titles": {
|
||||
"installedAstrBotPlugins": "已安装的 AstrBot 插件"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索插件...",
|
||||
"marketPlaceholder": "搜索市场插件..."
|
||||
@@ -225,7 +222,7 @@
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteFailed": "删除失败",
|
||||
"runtimeNoneWarning": "Computer Use 运行环境为无,Skills 可能无法正确被 Agent 运行,因为没有启用运行环境。",
|
||||
"runtimeHint": "需要在配置的 “使用电脑能力” 中将运行环境设置为 “local”、“local_sandboxed” 或 “sandbox” 才能让 AstrBot 正常使用你提供的 Skills。"
|
||||
"runtimeHint": "需要在配置的 “使用电脑能力” 中将运行环境设置为 “local” 或 “sandbox” 才能让 AstrBot 正常使用你提供的 Skills。"
|
||||
},
|
||||
"card": {
|
||||
"actions": {
|
||||
|
||||
@@ -61,7 +61,6 @@ export function getTutorialLink(platformType) {
|
||||
"vocechat": "https://docs.astrbot.app/deploy/platform/vocechat.html",
|
||||
"satori": "https://docs.astrbot.app/deploy/platform/satori/llonebot.html",
|
||||
"misskey": "https://docs.astrbot.app/deploy/platform/misskey.html",
|
||||
"line": "https://docs.astrbot.app/deploy/platform/line.html",
|
||||
}
|
||||
return tutorialMap[platformType] || "https://docs.astrbot.app";
|
||||
}
|
||||
|
||||
+2181
-248
File diff suppressed because it is too large
Load Diff
@@ -333,53 +333,12 @@ const loadApiKeys = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const tryExecCommandCopy = (text) => {
|
||||
let textArea = null;
|
||||
try {
|
||||
if (typeof document === 'undefined' || !document.body) return false;
|
||||
textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.setAttribute('readonly', '');
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
textArea.style.pointerEvents = 'none';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
textArea.setSelectionRange(0, text.length);
|
||||
return document.execCommand('copy');
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
if (textArea?.parentNode) {
|
||||
textArea.parentNode.removeChild(textArea);
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyTextToClipboard = async (text) => {
|
||||
if (!text) return false;
|
||||
if (tryExecCommandCopy(text)) return true;
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) return false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyCreatedApiKey = async () => {
|
||||
if (!createdApiKeyPlaintext.value) return;
|
||||
const ok = await copyTextToClipboard(createdApiKeyPlaintext.value);
|
||||
if (ok) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdApiKeyPlaintext.value);
|
||||
showToast(tm('apiKey.messages.copySuccess'), 'success');
|
||||
} else {
|
||||
} catch (_) {
|
||||
showToast(tm('apiKey.messages.copyFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,639 +0,0 @@
|
||||
<script setup>
|
||||
import ExtensionCard from "@/components/shared/ExtensionCard.vue";
|
||||
import StyledMenu from "@/components/shared/StyledMenu.vue";
|
||||
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
|
||||
|
||||
const props = defineProps({
|
||||
state: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
commonStore,
|
||||
t,
|
||||
tm,
|
||||
router,
|
||||
route,
|
||||
getSelectedGitHubProxy,
|
||||
conflictDialog,
|
||||
checkAndPromptConflicts,
|
||||
handleConflictConfirm,
|
||||
fileInput,
|
||||
activeTab,
|
||||
validTabs,
|
||||
isValidTab,
|
||||
getLocationHash,
|
||||
extractTabFromHash,
|
||||
syncTabFromHash,
|
||||
extension_data,
|
||||
getInitialShowReserved,
|
||||
showReserved,
|
||||
snack_message,
|
||||
snack_show,
|
||||
snack_success,
|
||||
configDialog,
|
||||
extension_config,
|
||||
pluginMarketData,
|
||||
loadingDialog,
|
||||
showPluginInfoDialog,
|
||||
selectedPlugin,
|
||||
curr_namespace,
|
||||
updatingAll,
|
||||
readmeDialog,
|
||||
forceUpdateDialog,
|
||||
updateAllConfirmDialog,
|
||||
changelogDialog,
|
||||
getInitialListViewMode,
|
||||
isListView,
|
||||
pluginSearch,
|
||||
loading_,
|
||||
currentPage,
|
||||
dangerConfirmDialog,
|
||||
selectedDangerPlugin,
|
||||
selectedMarketInstallPlugin,
|
||||
installCompat,
|
||||
versionCompatibilityDialog,
|
||||
showUninstallDialog,
|
||||
pluginToUninstall,
|
||||
showSourceDialog,
|
||||
showSourceManagerDialog,
|
||||
sourceName,
|
||||
sourceUrl,
|
||||
customSources,
|
||||
selectedSource,
|
||||
showRemoveSourceDialog,
|
||||
sourceToRemove,
|
||||
editingSource,
|
||||
originalSourceUrl,
|
||||
extension_url,
|
||||
dialog,
|
||||
upload_file,
|
||||
uploadTab,
|
||||
showPluginFullName,
|
||||
marketSearch,
|
||||
debouncedMarketSearch,
|
||||
refreshingMarket,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
randomPluginNames,
|
||||
normalizeStr,
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
marketCustomFilter,
|
||||
plugin_handler_info_headers,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
filteredPlugins,
|
||||
filteredMarketPlugins,
|
||||
sortedPlugins,
|
||||
RANDOM_PLUGINS_COUNT,
|
||||
randomPlugins,
|
||||
shufflePlugins,
|
||||
refreshRandomPlugins,
|
||||
displayItemsPerPage,
|
||||
totalPages,
|
||||
paginatedPlugins,
|
||||
updatableExtensions,
|
||||
toggleShowReserved,
|
||||
toast,
|
||||
resetLoadingDialog,
|
||||
onLoadingDialogResult,
|
||||
failedPluginsDict,
|
||||
getExtensions,
|
||||
handleReloadAllFailed,
|
||||
checkUpdate,
|
||||
uninstallExtension,
|
||||
handleUninstallConfirm,
|
||||
updateExtension,
|
||||
showUpdateAllConfirm,
|
||||
confirmUpdateAll,
|
||||
cancelUpdateAll,
|
||||
confirmForceUpdate,
|
||||
updateAllExtensions,
|
||||
pluginOn,
|
||||
pluginOff,
|
||||
openExtensionConfig,
|
||||
updateConfig,
|
||||
showPluginInfo,
|
||||
reloadPlugin,
|
||||
viewReadme,
|
||||
viewChangelog,
|
||||
handleInstallPlugin,
|
||||
confirmDangerInstall,
|
||||
cancelDangerInstall,
|
||||
loadCustomSources,
|
||||
saveCustomSources,
|
||||
addCustomSource,
|
||||
openSourceManagerDialog,
|
||||
selectPluginSource,
|
||||
sourceSelectItems,
|
||||
editCustomSource,
|
||||
removeCustomSource,
|
||||
confirmRemoveSource,
|
||||
saveCustomSource,
|
||||
trimExtensionName,
|
||||
checkAlreadyInstalled,
|
||||
showVersionCompatibilityWarning,
|
||||
continueInstallIgnoringVersionWarning,
|
||||
cancelInstallOnVersionWarning,
|
||||
newExtension,
|
||||
normalizePlatformList,
|
||||
getPlatformDisplayList,
|
||||
resolveSelectedInstallPlugin,
|
||||
selectedInstallPlugin,
|
||||
checkInstallCompatibility,
|
||||
refreshPluginMarket,
|
||||
handleLocaleChange,
|
||||
searchDebounceTimer,
|
||||
} = props.state;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-tab-item v-show="activeTab === 'installed'">
|
||||
<div class="mb-4 pt-4 pb-4">
|
||||
<div class="d-flex align-center flex-wrap" style="gap: 12px">
|
||||
<h2 class="text-h2 mb-0">{{ tm("titles.installedAstrBotPlugins") }}</h2>
|
||||
|
||||
<div class="d-flex align-center flex-wrap ml-auto" style="gap: 8px">
|
||||
<v-text-field
|
||||
v-model="pluginSearch"
|
||||
density="compact"
|
||||
:label="tm('search.placeholder')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
hide-details
|
||||
single-line
|
||||
style="min-width: 220px; max-width: 340px"
|
||||
>
|
||||
</v-text-field>
|
||||
|
||||
<v-btn-toggle
|
||||
v-model="isListView"
|
||||
mandatory
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="view-mode-toggle"
|
||||
>
|
||||
<v-btn :value="false" icon="mdi-view-grid"></v-btn>
|
||||
<v-btn :value="true" icon="mdi-view-list"></v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" class="d-flex align-center flex-wrap ga-2">
|
||||
<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
|
||||
class="ml-2"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
:disabled="updatableExtensions.length === 0"
|
||||
:loading="updatingAll"
|
||||
@click="showUpdateAllConfirm"
|
||||
>
|
||||
<v-icon>mdi-update</v-icon>
|
||||
{{ tm("buttons.updateAll") }}
|
||||
</v-btn>
|
||||
|
||||
<v-dialog max-width="500px" v-if="extension_data.message">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
size="small"
|
||||
color="error"
|
||||
class="ml-auto"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon>mdi-alert-circle</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:default="{ isActive }">
|
||||
<v-card class="rounded-lg">
|
||||
<v-card-title class="headline d-flex align-center">
|
||||
<v-icon color="error" class="mr-2"
|
||||
>mdi-alert-circle</v-icon
|
||||
>
|
||||
{{ tm("dialogs.error.title") }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-1">
|
||||
{{ extension_data.message }}
|
||||
</p>
|
||||
<p class="text-caption mt-2">
|
||||
{{ tm("dialogs.error.checkConsole") }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="handleReloadAllFailed"
|
||||
>
|
||||
尝试一键重载修复
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="isActive.value = false"
|
||||
>{{ tm("buttons.close") }}</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-dialog>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-fade-transition hide-on-leave>
|
||||
<!-- 表格视图 -->
|
||||
<div v-if="isListView">
|
||||
<v-card class="rounded-lg overflow-hidden elevation-0">
|
||||
<v-data-table
|
||||
:headers="pluginHeaders"
|
||||
:items="filteredPlugins"
|
||||
:loading="loading_"
|
||||
item-key="name"
|
||||
hover
|
||||
>
|
||||
<template v-slot:loader>
|
||||
<v-row class="py-8 d-flex align-center justify-center">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
></v-progress-circular>
|
||||
<span class="ml-2">{{ tm("status.loading") }}</span>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<template v-slot: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;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="mr-3" style="flex-shrink: 0">
|
||||
<img
|
||||
:src="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;">
|
||||
{{
|
||||
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"
|
||||
>
|
||||
{{ 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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot: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;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
"
|
||||
>
|
||||
{{ item.desc }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.support_platforms?.length"
|
||||
class="d-flex align-center flex-wrap mt-2"
|
||||
>
|
||||
<span class="text-caption text-medium-emphasis mr-2">
|
||||
{{ tm("card.status.supportPlatform") }}:
|
||||
</span>
|
||||
<v-chip
|
||||
v-for="platformId in item.support_platforms"
|
||||
:key="platformId"
|
||||
size="x-small"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ platformId }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.astrbot_version"
|
||||
class="d-flex align-center flex-wrap mt-1"
|
||||
>
|
||||
<span class="text-caption text-medium-emphasis mr-2">
|
||||
{{ tm("card.status.astrbotVersion") }}:
|
||||
</span>
|
||||
<v-chip
|
||||
size="x-small"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ item.astrbot_version }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.version="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<span class="text-body-2">{{ item.version }}</span>
|
||||
<v-icon
|
||||
v-if="item.has_update"
|
||||
color="warning"
|
||||
size="small"
|
||||
class="ml-1"
|
||||
>mdi-alert</v-icon
|
||||
>
|
||||
<v-tooltip v-if="item.has_update" activator="parent">
|
||||
<span
|
||||
>{{ tm("messages.hasUpdate") }}
|
||||
{{ item.online_version }}</span
|
||||
>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.author="{ item }">
|
||||
<div class="text-body-2">{{ item.author }}</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<div class="table-action-row d-flex align-center flex-nowrap ga-2 py-1">
|
||||
<v-btn
|
||||
v-if="!item.activated"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="success"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-play"
|
||||
@click="pluginOn(item)"
|
||||
>
|
||||
{{ tm("buttons.enable") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="error"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-pause"
|
||||
@click="pluginOff(item)"
|
||||
>
|
||||
{{ tm("buttons.disable") }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="reloadPlugin(item.name)"
|
||||
>
|
||||
{{ tm("buttons.reload") }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-cog"
|
||||
@click="openExtensionConfig(item.name)"
|
||||
>
|
||||
{{ tm("buttons.configure") }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-book-open-page-variant"
|
||||
:disabled="!item.repo"
|
||||
@click="item.repo && viewReadme(item)"
|
||||
>
|
||||
{{ tm("buttons.viewDocs") }}
|
||||
</v-btn>
|
||||
|
||||
<StyledMenu location="bottom end" offset="8">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon="mdi-dots-horizontal"
|
||||
size="small"
|
||||
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
|
||||
class="styled-menu-item"
|
||||
prepend-icon="mdi-update"
|
||||
@click="updateExtension(item.name)"
|
||||
>
|
||||
<v-list-item-title>{{ tm("buttons.update") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
prepend-icon="mdi-delete"
|
||||
:disabled="item.reserved"
|
||||
@click="uninstallExtension(item.name)"
|
||||
>
|
||||
<v-list-item-title>{{ tm("buttons.uninstall") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</StyledMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:no-data>
|
||||
<div class="text-center pa-8">
|
||||
<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>
|
||||
</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"
|
||||
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-fade-transition>
|
||||
|
||||
<v-tooltip :text="tm('market.installPlugin')" location="left">
|
||||
<template v-slot:activator="{ props }">
|
||||
<button
|
||||
v-bind="props"
|
||||
type="button"
|
||||
class="v-btn v-btn--elevated v-btn--icon v-theme--PurpleThemeDark bg-darkprimary v-btn--density-default v-btn--size-x-large v-btn--variant-elevated fab-button"
|
||||
style="
|
||||
position: fixed;
|
||||
right: 52px;
|
||||
bottom: 52px;
|
||||
z-index: 10000;
|
||||
border-radius: 16px;
|
||||
"
|
||||
@click="dialog = true"
|
||||
>
|
||||
<span class="v-btn__overlay"></span>
|
||||
<span class="v-btn__underlay"></span>
|
||||
<span class="v-btn__content" data-no-activator="">
|
||||
<i
|
||||
class="mdi-plus mdi v-icon notranslate v-theme--PurpleThemeDark v-icon--size-default"
|
||||
aria-hidden="true"
|
||||
style="font-size: 32px"
|
||||
></i>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-tab-item>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view-mode-toggle :deep(.v-btn) {
|
||||
min-width: 30px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.table-action-btn {
|
||||
min-height: 34px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-action-row {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fab-button {
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.fab-button:hover {
|
||||
transform: translateY(-4px) scale(1.05);
|
||||
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
|
||||
}
|
||||
</style>
|
||||
@@ -1,373 +0,0 @@
|
||||
<script setup>
|
||||
import MarketPluginCard from "@/components/extension/MarketPluginCard.vue";
|
||||
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
state: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
commonStore,
|
||||
t,
|
||||
tm,
|
||||
router,
|
||||
route,
|
||||
getSelectedGitHubProxy,
|
||||
conflictDialog,
|
||||
checkAndPromptConflicts,
|
||||
handleConflictConfirm,
|
||||
fileInput,
|
||||
activeTab,
|
||||
validTabs,
|
||||
isValidTab,
|
||||
getLocationHash,
|
||||
extractTabFromHash,
|
||||
syncTabFromHash,
|
||||
extension_data,
|
||||
getInitialShowReserved,
|
||||
showReserved,
|
||||
snack_message,
|
||||
snack_show,
|
||||
snack_success,
|
||||
configDialog,
|
||||
extension_config,
|
||||
pluginMarketData,
|
||||
loadingDialog,
|
||||
showPluginInfoDialog,
|
||||
selectedPlugin,
|
||||
curr_namespace,
|
||||
updatingAll,
|
||||
readmeDialog,
|
||||
forceUpdateDialog,
|
||||
updateAllConfirmDialog,
|
||||
changelogDialog,
|
||||
getInitialListViewMode,
|
||||
isListView,
|
||||
pluginSearch,
|
||||
loading_,
|
||||
currentPage,
|
||||
dangerConfirmDialog,
|
||||
selectedDangerPlugin,
|
||||
selectedMarketInstallPlugin,
|
||||
installCompat,
|
||||
versionCompatibilityDialog,
|
||||
showUninstallDialog,
|
||||
pluginToUninstall,
|
||||
showSourceDialog,
|
||||
showSourceManagerDialog,
|
||||
sourceName,
|
||||
sourceUrl,
|
||||
customSources,
|
||||
selectedSource,
|
||||
showRemoveSourceDialog,
|
||||
sourceToRemove,
|
||||
editingSource,
|
||||
originalSourceUrl,
|
||||
extension_url,
|
||||
dialog,
|
||||
upload_file,
|
||||
uploadTab,
|
||||
showPluginFullName,
|
||||
marketSearch,
|
||||
debouncedMarketSearch,
|
||||
refreshingMarket,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
randomPluginNames,
|
||||
normalizeStr,
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
marketCustomFilter,
|
||||
plugin_handler_info_headers,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
filteredPlugins,
|
||||
filteredMarketPlugins,
|
||||
sortedPlugins,
|
||||
RANDOM_PLUGINS_COUNT,
|
||||
randomPlugins,
|
||||
shufflePlugins,
|
||||
refreshRandomPlugins,
|
||||
displayItemsPerPage,
|
||||
totalPages,
|
||||
paginatedPlugins,
|
||||
updatableExtensions,
|
||||
toggleShowReserved,
|
||||
toast,
|
||||
resetLoadingDialog,
|
||||
onLoadingDialogResult,
|
||||
failedPluginsDict,
|
||||
getExtensions,
|
||||
handleReloadAllFailed,
|
||||
checkUpdate,
|
||||
uninstallExtension,
|
||||
handleUninstallConfirm,
|
||||
updateExtension,
|
||||
showUpdateAllConfirm,
|
||||
confirmUpdateAll,
|
||||
cancelUpdateAll,
|
||||
confirmForceUpdate,
|
||||
updateAllExtensions,
|
||||
pluginOn,
|
||||
pluginOff,
|
||||
openExtensionConfig,
|
||||
updateConfig,
|
||||
showPluginInfo,
|
||||
reloadPlugin,
|
||||
viewReadme,
|
||||
viewChangelog,
|
||||
handleInstallPlugin,
|
||||
confirmDangerInstall,
|
||||
cancelDangerInstall,
|
||||
loadCustomSources,
|
||||
saveCustomSources,
|
||||
addCustomSource,
|
||||
openSourceManagerDialog,
|
||||
selectPluginSource,
|
||||
sourceSelectItems,
|
||||
editCustomSource,
|
||||
removeCustomSource,
|
||||
confirmRemoveSource,
|
||||
saveCustomSource,
|
||||
trimExtensionName,
|
||||
checkAlreadyInstalled,
|
||||
showVersionCompatibilityWarning,
|
||||
continueInstallIgnoringVersionWarning,
|
||||
cancelInstallOnVersionWarning,
|
||||
newExtension,
|
||||
normalizePlatformList,
|
||||
getPlatformDisplayList,
|
||||
resolveSelectedInstallPlugin,
|
||||
selectedInstallPlugin,
|
||||
checkInstallCompatibility,
|
||||
refreshPluginMarket,
|
||||
handleLocaleChange,
|
||||
searchDebounceTimer,
|
||||
} = props.state;
|
||||
|
||||
const currentSourceName = computed(() => {
|
||||
if (!selectedSource.value) {
|
||||
return tm("market.defaultSource");
|
||||
}
|
||||
const matched = customSources.value.find((s) => s.url === selectedSource.value);
|
||||
return matched?.name || tm("market.defaultSource");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-tab-item v-show="activeTab === 'market'">
|
||||
<div class="mb-6 pt-4 pb-4">
|
||||
<div class="d-flex align-center flex-wrap" style="gap: 12px">
|
||||
<h2 class="text-h2 mb-0">{{ tm("tabs.market") }}</h2>
|
||||
|
||||
<v-tooltip location="top" :text="tm('market.sourceManagement')">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
variant="tonal"
|
||||
rounded="md"
|
||||
color="primary"
|
||||
class="text-none px-2"
|
||||
@click="openSourceManagerDialog"
|
||||
>
|
||||
<v-icon size="18" class="mr-1">mdi-source-branch</v-icon>
|
||||
<span class="text-truncate" style="max-width: 180px">
|
||||
{{ currentSourceName }}
|
||||
</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-text-field
|
||||
v-model="marketSearch"
|
||||
density="compact"
|
||||
:label="tm('search.marketPlaceholder')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
hide-details
|
||||
single-line
|
||||
style="min-width: 220px; max-width: 340px"
|
||||
>
|
||||
</v-text-field>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="d-flex align-center text-caption text-medium-emphasis mt-2"
|
||||
style="color: grey; line-height: 1.4"
|
||||
>
|
||||
<v-icon size="16" class="mr-1">mdi-alert-outline</v-icon>
|
||||
<span>{{ tm("market.sourceSafetyWarning") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <small style="color: var(--v-theme-secondaryText);">每个插件都是作者无偿提供的的劳动成果。如果您喜欢某个插件,请 Star!</small> -->
|
||||
|
||||
<!-- FAB Button -->
|
||||
<v-tooltip :text="tm('market.installPlugin')" location="left">
|
||||
<template v-slot:activator="{ props }">
|
||||
<button
|
||||
v-bind="props"
|
||||
type="button"
|
||||
class="v-btn v-btn--elevated v-btn--icon v-theme--PurpleThemeDark bg-darkprimary v-btn--density-default v-btn--size-x-large v-btn--variant-elevated fab-button"
|
||||
style="
|
||||
position: fixed;
|
||||
right: 52px;
|
||||
bottom: 52px;
|
||||
z-index: 10000;
|
||||
border-radius: 16px;
|
||||
"
|
||||
@click="dialog = true"
|
||||
>
|
||||
<span class="v-btn__overlay"></span>
|
||||
<span class="v-btn__underlay"></span>
|
||||
<span class="v-btn__content" data-no-activator="">
|
||||
<i
|
||||
class="mdi-plus mdi v-icon notranslate v-theme--PurpleThemeDark v-icon--size-default"
|
||||
aria-hidden="true"
|
||||
style="font-size: 32px"
|
||||
></i>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<div class="mt-4">
|
||||
<div
|
||||
class="d-flex align-center mb-2"
|
||||
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
|
||||
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") }}({{
|
||||
filteredMarketPlugins.length
|
||||
}})
|
||||
</h2>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
@click="refreshPluginMarket"
|
||||
:loading="refreshingMarket"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="d-flex align-center"
|
||||
style="gap: 8px; flex-wrap: wrap"
|
||||
>
|
||||
<v-select
|
||||
v-model="sortBy"
|
||||
:items="[
|
||||
{ title: tm('sort.default'), value: 'default' },
|
||||
{ title: tm('sort.stars'), value: 'stars' },
|
||||
{ title: tm('sort.author'), value: 'author' },
|
||||
{ title: tm('sort.updated'), value: 'updated' },
|
||||
]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
style="max-width: 150px"
|
||||
>
|
||||
<template v-slot:prepend-inner>
|
||||
<v-icon size="small">mdi-sort</v-icon>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
v-if="sortBy !== 'default'"
|
||||
@click="sortOrder = sortOrder === 'desc' ? 'asc' : 'desc'"
|
||||
variant="text"
|
||||
density="compact"
|
||||
>
|
||||
<v-icon>{{
|
||||
sortOrder === "desc"
|
||||
? "mdi-sort-descending"
|
||||
: "mdi-sort-ascending"
|
||||
}}</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{
|
||||
sortOrder === "desc"
|
||||
? tm("sort.descending")
|
||||
: tm("sort.ascending")
|
||||
}}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
</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>
|
||||
|
||||
<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-tab-item>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.18.3"
|
||||
version = "4.18.2"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
Reference in New Issue
Block a user