Compare commits

...

23 Commits

Author SHA1 Message Date
Soulter 6948fac7b6 feat: support bwrap/seatbelt-based local sandbox runtime for Computer Use 2026-02-27 00:20:01 +08:00
Soulter 84994b5d98 chore: bump version to 4.18.3 2026-02-26 19:12:09 +08:00
圣达生物多 1554f71106 [bug]查看介入教程line前往错误界面的问题 (#5479)
Fixes #5478
2026-02-26 19:04:53 +08:00
Soulter 476c01469f fix(line): line adapter does not appear in the add platform dialog
fixes: #5477
2026-02-26 15:26:37 +08:00
Dt8333 10163ec78a chore: 为类型检查添加 TYPE_CHECKING 的导入与阶段类型引用 (#5474) 2026-02-26 14:19:52 +08:00
Waterwzy 98b89ebcc5 fix:fix the issue where incomplete cleanup of residual plugins occurs… (#5462)
* fix:fix the issue where incomplete cleanup of residual plugins occurs in the failed loading of plugins

* fix:ruff format,apply bot suggestions

* Apply suggestion from @gemini-code-assist[bot]

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-26 10:02:43 +08:00
CAICAII 39b9e55434 perf: batch metadata query in KB retrieval to fix N+1 problem (#5463)
* perf: batch metadata query in KB retrieval to fix N+1 problem

Replace N sequential get_document_with_metadata() calls with a single
get_documents_with_metadata_batch() call using SQL IN clause.

Benchmark results (local SQLite):
- 10 docs: 10.67ms → 1.47ms (7.3x faster)
- 20 docs: 26.00ms → 2.68ms (9.7x faster)
- 50 docs: 63.87ms → 2.79ms (22.9x faster)

* refactor: use set[str] param type and chunk IN clause for SQLite safety

Address review feedback:
- Change doc_ids param from list[str] to set[str] to avoid unnecessary conversion
- Chunk IN clause into batches of 900 to stay under SQLite's 999 parameter limit
- Remove list() wrapping at call site, pass set directly
2026-02-26 09:59:37 +08:00
CCCCCCTV 3eb15089af fix(telegram): avoid duplicate message_thread_id in streaming (#5430) 2026-02-25 19:53:17 +08:00
Dt8333 c5b23d12a8 fix: 修复Pyright静态类型检查报错 (#5437)
* refactor: 修正 Sqlite 查询、下载回调、接口重构与类型调整

* feat: 为 OneBotClient 增加 CallAction 协议与异步调用支持
2026-02-25 19:49:16 +08:00
exynos 69f2fb291a fix: cannot automatically get embedding dim when create embedding provider (#5442)
* fix(dashboard): 强化 API Key 复制临时节点清理逻辑

* fix(embedding): 自动检测改为探测 OpenAI embedding 最大可用维度

* fix: normalize openai embedding base url and add hint key

* i18n: add embedding_api_base hint translations

* i18n: localize provider embedding/proxy metadata hints

* fix: show provider-specific embedding API Base URL hint as field subtitle

* fix(embedding): cap OpenAI detect_dim probes with early short-circuit

* fix(dashboard): return generic error on provider adapter import failure

* 回退检测逻辑
2026-02-25 19:48:03 +08:00
時壹 78660da995 fix: clear markdown field when sending media messages via QQ Official Platform (#5445)
* fix: clear markdown field when sending media messages via QQ Official API

* refactor: use pop() to remove markdown key instead of setting None
2026-02-25 19:45:27 +08:00
Soulter c951b14aa2 feat: add useExtensionPage composable for managing plugin extensions
- Implemented a new composable `useExtensionPage` to handle various functionalities related to plugin management, including fetching extensions, handling updates, and managing UI states.
- Added support for conflict checking, plugin installation, and custom source management.
- Integrated search and filtering capabilities for plugins in the market.
- Enhanced user experience with dialogs for confirmations and notifications.
- Included pagination and sorting features for better plugin visibility.
2026-02-25 19:42:51 +08:00
Soulter c384439b44 perf(cron): enhance future task session isolation
fixes: #5392
2026-02-25 16:32:09 +08:00
鸦羽 87d2750ff8 Merge pull request #5440 from Raven95676/fix/persona-loss
fix(persona): preserve conversation persona_id and unify session/conversation resolution
2026-02-25 16:11:19 +08:00
Luna_Dol 6d76d55452 fix: ensure tool call/response pairing in context truncation (#5417)
* fix: ensure tool call/response pairing in context truncation

* refactor: simplify fix_messages to single-pass state machine
2026-02-25 15:21:30 +08:00
Raven95676 d80598b9c3 fix(persona): enhance persona resolution logic for conversations and sessions 2026-02-25 15:14:46 +08:00
letr c7d318304b Fix: GitHub proxy not displaying correctly in WebUI (#5438)
* fix(dashboard): preserve custom GitHub proxy setting on reload

* fix(dashboard): keep github proxy selection persisted in settings
2026-02-25 14:54:54 +08:00
exynos bcdbc15635 fix(dashboard): 修复设置页新建 API Key 后复制失败问题 (#5439) 2026-02-25 14:54:06 +08:00
Raven95676 4749159bb9 fix(conversation): retain existing persona_id when updating conversation 2026-02-25 14:48:46 +08:00
エイカク 5530a2260a feat(dashboard): add generic desktop app updater bridge (#5424)
* feat(dashboard): add generic desktop app updater bridge

* fix(dashboard): address updater bridge review feedback

* fix(dashboard): unify updater bridge types and error logging

* fix(dashboard): consolidate updater bridge typings
2026-02-25 10:01:13 +09:00
Soulter c24de24ca4 chore: ruff format 2026-02-24 23:12:18 +08:00
Yunhao Cao b54b4c79ed fix: Telegram voice message format (OGG instead of WAV) causing issues with OpenAI STT API (#5389) 2026-02-24 23:11:56 +08:00
Soulter c6cc7aae84 chore: bump version to 4.18.2 2026-02-24 23:08:53 +08:00
50 changed files with 4163 additions and 2731 deletions
@@ -206,16 +206,33 @@ 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 = 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"]
(
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} (自定义规则)"
title = _titles.get(conv.cid, "新对话")
parts.append(
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_id}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\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 sp, star
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
if TYPE_CHECKING:
@@ -59,12 +59,7 @@ class PersonaCommands:
default_persona = await self.context.persona_manager.get_default_persona_v3(
umo=umo,
)
force_applied_persona_id = (
await sp.get_async(
scope="umo", scope_id=umo, key="session_service_config", default={}
)
).get("persona_id")
force_applied_persona_id = None
curr_cid_title = ""
if cid:
@@ -80,10 +75,27 @@ class PersonaCommands:
),
)
return
if not conv.persona_id and conv.persona_id != "[%None]":
curr_persona_name = default_persona["name"]
else:
curr_persona_name = conv.persona_id
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 force_applied_persona_id:
curr_persona_name = f"{curr_persona_name} (自定义规则)"
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.18.2"
__version__ = "4.18.3"
+53 -12
View File
@@ -4,19 +4,60 @@ 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]:
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)
"""修复消息列表,确保 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()
return fixed_messages
def truncate_by_turns(
+1 -1
View File
@@ -106,7 +106,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL,
FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL,
}
if runtime == "local":
if runtime in {"local", "local_sandboxed"}:
return {
LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL,
LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL,
+15 -38
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import builtins
import copy
import datetime
import json
@@ -10,7 +9,6 @@ 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
@@ -112,7 +110,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, or sandbox."""
"""The runtime for agent computer use: none, local, local_sandboxed, 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."""
@@ -275,47 +273,26 @@ async def _ensure_persona_and_skills(
if not req.conversation:
return
# 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,
(
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,
)
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
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
elif use_webchat_special_default:
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
# Inject skills prompt
runtime = cfg.get("computer_use_runtime", "local")
@@ -1073,7 +1050,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 == "local":
elif config.computer_use_runtime in {"local", "local_sandboxed"}:
_apply_local_env_tools(req)
agent_runner = AgentRunner()
+208 -56
View File
@@ -2,22 +2,24 @@ 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 typing import Any
from pathlib import Path
from typing import Any, Literal
from astrbot.api import logger
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_root,
get_astrbot_temp_path,
)
from astrbot.core.utils.astrbot_path import get_astrbot_root
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
SandboxBackend = Literal["none", "bwrap", "seatbelt"]
_BLOCKED_COMMAND_PATTERNS = [
" rm -rf ",
" rm -fr ",
@@ -40,20 +42,132 @@ def _is_safe_command(command: str) -> bool:
return not any(pat in cmd for pat in _BLOCKED_COMMAND_PATTERNS)
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
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.")
@dataclass
class LocalShellComponent(ShellComponent):
policy: LocalSandboxPolicy
async def exec(
self,
command: str,
@@ -67,41 +181,58 @@ 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 = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
working_dir = self.policy.normalize_working_dir(cwd)
wrapped_command = self.policy.wrap_command(shell_command, working_dir)
if background:
proc = subprocess.Popen(
command,
shell=shell,
wrapped_command,
shell=False,
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}
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,
}
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,
}
return await asyncio.to_thread(_run)
@dataclass
class LocalPythonComponent(PythonComponent):
policy: LocalSandboxPolicy
async def exec(
self,
code: str,
@@ -110,9 +241,13 @@ 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(
[os.environ.get("PYTHON", sys.executable), "-c", code],
wrapped_command,
cwd=working_dir,
timeout=timeout,
capture_output=True,
text=True,
@@ -138,23 +273,25 @@ 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 = _ensure_safe_path(path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, "w", encoding="utf-8") as f:
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:
f.write(content)
os.chmod(abs_path, mode)
return {"success": True, "path": abs_path}
abs_path.chmod(mode)
return {"success": True, "path": str(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 = _ensure_safe_path(path)
with open(abs_path, encoding=encoding) as f:
abs_path = self.policy.resolve_path(path)
with abs_path.open(encoding=encoding) as f:
content = f.read()
return {"success": True, "content": content}
@@ -164,22 +301,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 = _ensure_safe_path(path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, mode, encoding=encoding) as f:
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:
f.write(content)
return {"success": True, "path": abs_path}
return {"success": True, "path": str(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 = _ensure_safe_path(path)
if os.path.isdir(abs_path):
abs_path = self.policy.ensure_writable_path(path)
if abs_path.is_dir():
shutil.rmtree(abs_path)
else:
os.remove(abs_path)
return {"success": True, "path": abs_path}
abs_path.unlink()
return {"success": True, "path": str(abs_path)}
return await asyncio.to_thread(_run)
@@ -187,8 +324,8 @@ class LocalFileSystemComponent(FileSystemComponent):
self, path: str = ".", show_hidden: bool = False
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
entries = os.listdir(abs_path)
abs_path = self.policy.resolve_path(path)
entries = [entry.name for entry in abs_path.iterdir()]
if not show_hidden:
entries = [e for e in entries if not e.startswith(".")]
return {"success": True, "entries": entries}
@@ -197,13 +334,28 @@ class LocalFileSystemComponent(FileSystemComponent):
class LocalBooter(ComputerBooter):
def __init__(self) -> None:
self._fs = LocalFileSystemComponent()
self._python = LocalPythonComponent()
self._shell = LocalShellComponent()
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)
async def boot(self, session_id: str) -> None:
logger.info(f"Local computer booter initialized for session: {session_id}")
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})"
)
async def shutdown(self) -> None:
logger.info("Local computer booter shutdown complete.")
+6 -6
View File
@@ -15,7 +15,7 @@ from .booters.base import ComputerBooter
from .booters.local import LocalBooter
session_booter: dict[str, ComputerBooter] = {}
local_booter: ComputerBooter | None = None
local_booters: dict[tuple[str, bool], ComputerBooter] = {}
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() -> ComputerBooter:
global local_booter
if local_booter is None:
local_booter = LocalBooter()
return local_booter
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]
+13 -2
View File
@@ -83,7 +83,10 @@ class PythonTool(FunctionTool):
@dataclass
class LocalPythonTool(FunctionTool):
name: str = "astrbot_execute_python"
description: str = "Execute codes in a Python environment."
description: str = (
"Execute code in a local Python environment. "
"In local_sandboxed runtime, writes are restricted to ~/.astrbot/workspace/<session>."
)
parameters: dict = field(default_factory=lambda: param_schema)
@@ -92,7 +95,15 @@ class LocalPythonTool(FunctionTool):
) -> ToolExecResult:
if permission_error := check_admin_permission(context, "Python execution"):
return permission_error
sb = get_local_booter()
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",
)
try:
result = await sb.python.exec(code, silent=silent)
return await handle_result(result, context.context.event)
+27 -5
View File
@@ -13,14 +13,21 @@ from .permissions import check_admin_permission
@dataclass
class ExecuteShellTool(FunctionTool):
name: str = "astrbot_execute_shell"
description: str = "Execute a command in the shell."
description: str = (
"Execute a command in the shell. "
"In local_sandboxed runtime, writes are restricted to ~/.astrbot/workspace/<session>."
)
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute. Equal to 'cd {working_dir} && {your_command}'.",
"description": "The shell command to execute.",
},
"cwd": {
"type": "string",
"description": "Optional working directory for command execution.",
},
"background": {
"type": "boolean",
@@ -44,21 +51,36 @@ 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()
sb = get_local_booter(
event.unified_msg_origin,
sandboxed=runtime == "local_sandboxed",
)
else:
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
event.unified_msg_origin,
)
try:
result = await sb.shell.exec(command, background=background, env=env)
result = await sb.shell.exec(
command,
cwd=cwd,
background=background,
env=env,
)
return json.dumps(result)
except Exception as e:
return f"Error executing command: {str(e)}"
+16 -6
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.18.2"
VERSION = "4.18.3"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -425,7 +425,15 @@ CONFIG_METADATA_2 = {
"slack_webhook_port": 6197,
"slack_webhook_path": "/astrbot-slack-webhook/callback",
},
# LINE's config is located in line_adapter.py
"Line": {
"id": "line",
"type": "line",
"enable": False,
"channel_access_token": "",
"channel_secret": "",
"unified_webhook_mode": True,
"webhook_uuid": "",
},
"Satori": {
"id": "satori",
"type": "satori",
@@ -1463,6 +1471,7 @@ 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": "",
@@ -1476,6 +1485,7 @@ 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": "",
@@ -2192,9 +2202,9 @@ CONFIG_METADATA_2 = {
"type": "string",
},
"proxy": {
"description": "代理地址",
"description": "provider_group.provider.proxy.description",
"type": "string",
"hint": "HTTP/HTTPS 代理地址,格式如 http://127.0.0.1:7890。仅对该提供商的 API 请求生效,不影响 Docker 内网通信。",
"hint": "provider_group.provider.proxy.hint",
},
"model": {
"description": "模型 ID",
@@ -2762,8 +2772,8 @@ CONFIG_METADATA_3 = {
"provider_settings.computer_use_runtime": {
"description": "Computer Use Runtime",
"type": "string",
"options": ["none", "local", "sandbox"],
"labels": ["", "本地", "沙箱"],
"options": ["none", "local", "local_sandboxed", "sandbox"],
"labels": ["", "本地", "本地(沙箱增强)", "沙箱"],
"hint": "选择 Computer Use 运行环境。",
},
"provider_settings.computer_use_require_admin": {
+6 -6
View File
@@ -4,7 +4,7 @@ import typing as T
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone
from sqlalchemy import CursorResult
from sqlalchemy import CursorResult, Row
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), ApiKey.expires_at > now),
or_(col(ApiKey.expires_at).is_(None), col(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(ApiKey.key_id == key_id)
.where(col(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(ApiKey.key_id == key_id)
.where(col(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(ApiKey.key_id == key_id)
delete(ApiKey).where(col(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: list[tuple]) -> list[dict]:
def _rows_to_session_dicts(rows: T.Sequence[Row[tuple]]) -> list[dict]:
sessions_with_projects = []
for row in rows:
platform_session = row[0]
@@ -256,6 +256,46 @@ 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,10 +142,13 @@ class RetrievalManager:
f"Rank fusion took {time_end - time_start:.2f}s and returned {len(fused_results)} results.",
)
# 4. 转换为 RetrievalResult (获取元数据)
# 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)
retrieval_results = []
for fr in fused_results:
metadata_dict = await self.kb_db.get_document_with_metadata(fr.doc_id)
metadata_dict = metadata_map.get(fr.doc_id)
if metadata_dict:
retrieval_results.append(
RetrievalResult(
+55
View File
@@ -1,4 +1,5 @@
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
@@ -58,6 +59,60 @@ 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):
+12 -1
View File
@@ -8,7 +8,7 @@ resolution for backward compatibility.
from __future__ import annotations
from importlib import import_module
from typing import Any
from typing import TYPE_CHECKING, Any
from astrbot.core.message.message_event_result import (
EventResultType,
@@ -17,6 +17,17 @@ 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",
+4
View File
@@ -180,6 +180,10 @@ class PlatformManager:
from .sources.line.line_adapter import (
LinePlatformAdapter, # noqa: F401
)
case "email":
from .sources.email.email_adapter import (
EmailPlatformAdapter, # noqa: F401
)
except (ImportError, ModuleNotFoundError) as e:
logger.error(
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
@@ -65,15 +65,6 @@ 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,6 +162,8 @@ 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,
@@ -170,6 +172,8 @@ 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
@@ -188,6 +192,8 @@ 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,
@@ -196,6 +202,8 @@ 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(
@@ -1,7 +1,9 @@
import asyncio
import os
import re
import sys
import uuid
from typing import cast
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from telegram import BotCommand, Update
@@ -25,6 +27,9 @@ from astrbot.core.star.filter.command import CommandFilter
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.media_utils import convert_audio_to_wav
from .tg_event import TelegramPlatformEvent
@@ -375,8 +380,19 @@ class TelegramPlatformAdapter(Platform):
elif update.message.voice:
file = await update.message.voice.get_file()
file_basename = os.path.basename(cast(str, 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)
path_wav = os.path.join(
temp_dir,
f"{file_basename}.wav",
)
path_wav = await convert_audio_to_wav(temp_path, path_wav)
message.message = [
Comp.Record(file=file.file_path, url=file.file_path),
Comp.Record(file=path_wav, url=path_wav),
]
elif update.message.photo:
@@ -18,6 +18,7 @@ from astrbot.api.message_components import (
Plain,
Record,
Reply,
Video,
)
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
@@ -36,6 +37,7 @@ 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,
@@ -114,10 +116,18 @@ class TelegramPlatformEvent(AstrMessageEvent):
**payload: Any,
) -> None:
"""发送媒体时显示 upload action,发送完成后恢复 typing"""
await cls._send_chat_action(client, user_name, upload_action, message_thread_id)
await send_coro(**payload)
effective_thread_id = message_thread_id or cast(
str | None, payload.get("message_thread_id")
)
await cls._send_chat_action(
client, user_name, ChatAction.TYPING, message_thread_id
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
)
@classmethod
@@ -141,14 +151,16 @@ 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, payload),
**cast(Any, media_payload),
)
else:
await client.send_voice(voice=path, **cast(Any, payload))
@@ -162,15 +174,17 @@ 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, payload),
**cast(Any, media_payload),
)
else:
await client.send_document(
@@ -278,6 +292,13 @@ 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:
@@ -333,7 +354,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
"chat_id": user_name,
}
if message_thread_id:
payload["reply_to_message_id"] = message_thread_id
payload["message_thread_id"] = message_thread_id
delta = ""
current_content = ""
@@ -375,7 +396,6 @@ 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),
)
@@ -388,7 +408,6 @@ 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),
@@ -406,6 +425,17 @@ 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 Awaitable, Callable
from collections.abc import Callable, Coroutine
from typing import Any, cast
import quart
@@ -65,7 +65,9 @@ class WeixinOfficialAccountServer:
self.event_queue = event_queue
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
self.callback: (
Callable[[BaseMessage], Coroutine[Any, Any, str | None]] | None
) = None
self.shutdown_event = asyncio.Event()
self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复
@@ -23,12 +23,16 @@ 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=provider_config.get(
"embedding_api_base",
"https://api.openai.com/v1",
),
base_url=api_base,
timeout=int(provider_config.get("timeout", 20)),
http_client=http_client,
)
+16
View File
@@ -105,6 +105,22 @@ 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,
+41 -10
View File
@@ -388,6 +388,33 @@ 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):
"""
重新加载未注册加载失败的插件
@@ -398,17 +425,21 @@ class PluginManager:
- success (bool): 重载是否成功
- error_message (str|None): 错误信息成功时为 None
"""
async with self._pm_lock:
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, "插件不存在于失败列表中"
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
async def reload(self, specified_plugin_name=None):
"""重新加载插件
+21 -1
View File
@@ -1,4 +1,5 @@
from datetime import datetime
from typing import Any
from pydantic import Field
from pydantic.dataclasses import dataclass
@@ -8,6 +9,14 @@ 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"
@@ -119,9 +128,15 @@ 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}."
@@ -148,8 +163,13 @@ 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 = await cron_mgr.list_jobs(job_type)
jobs = [
job
for job in await cron_mgr.list_jobs(job_type)
if _extract_job_session(job) == current_umo
]
if not jobs:
return "No cron jobs found."
lines = []
@@ -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, normalize_file_like_url
from .image_refs import looks_like_image_file_name
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(normalize_file_like_url(candidate_url))
and looks_like_image_file_name(candidate_url)
):
image_refs.append(candidate_url.strip())
candidate_file = seg_data.get("file")
@@ -308,11 +308,7 @@ def _parse_onebot_segments(
isinstance(candidate_file, str)
and candidate_file.strip()
and looks_like_image_file_name(
normalize_file_like_url(
seg_data.get("name")
or seg_data.get("file_name")
or candidate_file
)
seg_data.get("name") or seg_data.get("file_name") or candidate_file
)
):
image_refs.append(candidate_file.strip())
@@ -368,7 +364,9 @@ def _extract_text_forward_ids_and_images_from_forward_nodes(
if not isinstance(node, dict):
continue
sender = node.get("sender") if isinstance(node.get("sender"), dict) else {}
sender = node.get("sender")
if not isinstance(sender, dict):
sender = {}
sender_name = (
sender.get("nickname")
or sender.get("card")
@@ -1,6 +1,7 @@
from __future__ import annotations
from typing import Any
from collections.abc import Awaitable
from typing import Any, Protocol
from astrbot import logger
from astrbot.core.platform.astr_message_event import AstrMessageEvent
@@ -17,6 +18,10 @@ 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,
@@ -27,7 +32,7 @@ class OneBotClient:
self._settings = settings
@staticmethod
def _resolve_call_action(event: AstrMessageEvent):
def _resolve_call_action(event: AstrMessageEvent) -> CallAction | None:
bot = getattr(event, "bot", None)
api = getattr(bot, "api", None)
call_action = getattr(api, "call_action", None)
+17 -1
View File
@@ -754,6 +754,22 @@ 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 (
@@ -779,7 +795,7 @@ class ConfigRoute(Route):
if inspect.iscoroutinefunction(init_fn):
await init_fn()
# 获取嵌入向量维度
# 通过实际请求验证当前 embedding_dimensions 是否可用
vec = await inst.get_embedding("echo")
dim = len(vec)
+3 -1
View File
@@ -148,7 +148,6 @@ 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__
@@ -158,6 +157,9 @@ 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,
+49
View File
@@ -0,0 +1,49 @@
## 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,6 +34,7 @@ const platformDisplayList = computed(() =>
const handleInstall = (plugin) => {
emit("install", plugin);
};
</script>
<template>
@@ -123,6 +124,7 @@ 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;
@@ -213,7 +215,10 @@ const handleInstall = (plugin) => {
</div>
</v-card-text>
<v-card-actions style="gap: 6px; padding: 8px 12px; padding-top: 0">
<v-card-actions
style="gap: 6px; padding: 8px 12px; padding-top: 0"
@click.stop
>
<v-chip
v-for="tag in plugin.tags?.slice(0, 2)"
:key="tag"
@@ -248,22 +253,24 @@ const handleInstall = (plugin) => {
<v-btn
v-if="plugin?.repo"
color="secondary"
size="x-small"
size="small"
variant="tonal"
class="market-action-btn"
:href="plugin.repo"
target="_blank"
style="height: 24px"
style="height: 32px"
>
<v-icon icon="mdi-github" start size="x-small"></v-icon>
<v-icon icon="mdi-github" start size="small"></v-icon>
{{ tm("buttons.viewRepo") }}
</v-btn>
<v-btn
v-if="!plugin?.installed"
color="primary"
size="x-small"
size="small"
@click="handleInstall(plugin)"
variant="flat"
style="height: 24px"
class="market-action-btn"
style="height: 32px"
>
{{ tm("buttons.install") }}
</v-btn>
@@ -306,4 +313,9 @@ 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,6 +48,40 @@ 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')
@@ -153,14 +187,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="iterable.hint && !isEditing"
v-if="providerHint"
type="info"
variant="tonal"
class="mb-4"
border="start"
density="compact"
>
{{ iterable.hint }}
{{ translateIfKey(providerHint) }}
</v-alert>
<div v-for="(val, key, index) in filteredIterable" :key="key" class="config-item">
@@ -218,9 +252,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 && metadata[metadataKey].items[key]?.hint"
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && getItemHint(key, metadata[metadataKey].items[key])"
class="important-hint"></span>
{{ translateIfKey(metadata[metadataKey].items[key]?.hint) }}
{{ translateIfKey(getItemHint(key, metadata[metadataKey].items[key])) }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
+360 -235
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref, computed, inject } from "vue";
import { ref, computed, inject, watch } 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: {
@@ -59,6 +61,25 @@ 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);
@@ -104,6 +125,7 @@ const viewReadme = () => {
const viewChangelog = () => {
emit("view-changelog", props.extension);
};
</script>
<template>
@@ -129,249 +151,292 @@ const viewChangelog = () => {
style="
padding: 16px;
padding-bottom: 0px;
display: flex;
gap: 16px;
width: 100%;
"
>
<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"
>
<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-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"
>
<v-list-item-title>
{{ tm("buttons.install") }}</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>
<!-- Divider between market actions and plugin actions -->
<v-divider v-if="!marketMode" />
<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 @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="overflow-x: auto; width: 100%">
<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"
<div class="extension-title-row">
<p
class="text-h3 font-weight-black extension-title"
:class="{ 'text-h4': $vuetify.display.xs }"
>
<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
location="top"
:text="
extension.display_name?.length &&
extension.display_name !== extension.name
? `${extension.display_name} (${extension.name})`
: extension.name
"
>
</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>
<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-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>
<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>
<v-list-item @click="viewReadme">
<v-list-item-title
>📄 {{ tm("buttons.viewDocs") }}</v-list-item-title
>
</v-list-item>
<v-list-item
v-if="marketMode && !extension?.installed"
@click="installExtension"
>
<v-list-item-title>
{{ tm("buttons.install") }}</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>
</div>
<div
class="mt-2"
:class="{ 'text-caption': $vuetify.display.xs }"
style="overflow-y: auto; height: 70px; font-size: 90%"
>
{{ extension.desc }}
<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>
</div>
</div>
</v-card-text>
<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 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>
</v-card>
@@ -385,13 +450,52 @@ const viewChangelog = () => {
<style scoped>
.extension-image-container {
display: flex;
align-items: center;
margin-left: 12px;
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;
}
.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 {
@@ -399,17 +503,38 @@ const viewChangelog = () => {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-top: 6px;
}
.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;
}
@media (max-width: 600px) {
.extension-image-container {
margin-left: 8px;
.extension-content-row {
flex-direction: column;
}
.extension-logo {
width: 64px;
height: 64px;
}
}
.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="idx">
<v-radio color="success" v-for="(proxy, idx) in githubProxies" :key="proxy" :value="String(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="githubProxyRadioControl === '-1'">
<template v-slot:label v-if="String(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,9 +72,21 @@ 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;
@@ -118,42 +130,60 @@ export default {
},
},
mounted() {
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] || "";
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;
}
} 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 (newVal === "0") {
if (String(newVal) === "0") {
this.selectedGitHubProxy = "";
} else if (this.githubProxyRadioControl !== "-1") {
this.selectedGitHubProxy = this.githubProxies[this.githubProxyRadioControl] || "";
} else if (String(this.githubProxyRadioControl) !== "-1") {
this.selectedGitHubProxy = this.getProxyByControl(this.githubProxyRadioControl);
}
},
githubProxyRadioControl: function (newVal) {
localStorage.setItem('githubProxyRadioControl', newVal);
if (this.radioValue !== "1") {
if (this.initializing) {
return;
}
const normalizedVal = String(newVal);
localStorage.setItem('githubProxyRadioControl', normalizedVal);
if (String(this.radioValue) !== "1") {
this.selectedGitHubProxy = "";
return;
}
if (newVal !== "-1") {
this.selectedGitHubProxy = this.githubProxies[newVal] || "";
} else {
this.selectedGitHubProxy = "";
if (normalizedVal !== "-1") {
this.selectedGitHubProxy = this.getProxyByControl(normalizedVal);
}
}
}
@@ -58,6 +58,18 @@
"guideStep2": "Install it and restart AstrBot.",
"guideStep3": "If you use Docker, prefer the image update path."
},
"desktopApp": {
"title": "Update Desktop App",
"message": "Check and upgrade the AstrBot desktop application.",
"currentVersion": "Current version: ",
"latestVersion": "Latest version: ",
"checking": "Checking desktop app updates...",
"hasNewVersion": "A new version is available. Click confirm to upgrade.",
"isLatest": "Already on the latest version",
"installing": "Downloading and installing update. The app will restart automatically...",
"checkFailed": "Failed to check updates. Please try again later.",
"installFailed": "Upgrade failed. Please try again later."
},
"dashboardUpdate": {
"title": "Update Dashboard to Latest Version Only",
"currentVersion": "Current Version",
@@ -147,7 +147,7 @@
"provider_settings": {
"computer_use_runtime": {
"description": "Computer Use Runtime",
"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."
"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."
},
"computer_use_require_admin": {
"description": "Require AstrBot Admin Permission",
@@ -1086,6 +1086,12 @@
"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."
@@ -1313,6 +1319,10 @@
"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,6 +8,9 @@
"handlersOperation": "Manage Handlers",
"market": "AstrBot Plugin Market"
},
"titles": {
"installedAstrBotPlugins": "Installed AstrBot Plugins"
},
"search": {
"placeholder": "Search extensions...",
"marketPlaceholder": "Search market extensions..."
@@ -222,7 +225,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 or Sandbox in settings so AstrBot can use your Skills."
"runtimeHint": "Set the Computer Use runtime to Local, Local Sandboxed, or Sandbox in settings so AstrBot can use your Skills."
},
"card": {
"actions": {
@@ -58,6 +58,18 @@
"guideStep2": "完成安装后重启 AstrBot。",
"guideStep3": "如果你使用 Docker,请优先使用镜像更新方式。"
},
"desktopApp": {
"title": "更新桌面应用",
"message": "将检查并升级 AstrBot 桌面端程序。",
"currentVersion": "当前版本:",
"latestVersion": "最新版本:",
"checking": "正在检查桌面应用更新...",
"hasNewVersion": "发现新版本,可点击确认升级。",
"isLatest": "已经是最新版本",
"installing": "正在下载并安装更新,完成后将自动重启应用...",
"checkFailed": "检查更新失败,请稍后重试。",
"installFailed": "升级失败,请稍后重试。"
},
"dashboardUpdate": {
"title": "单独更新管理面板到最新版本",
"currentVersion": "当前版本",
@@ -150,7 +150,7 @@
"provider_settings": {
"computer_use_runtime": {
"description": "运行环境",
"hint": "sandbox 代表在沙箱环境中运行, local 代表在本地环境中运行, none 代表不启用。如果上传了 skills,选择 none 会导致其无法被 Agent 正常使用。"
"hint": "sandbox 代表在远程沙箱环境中运行, local 代表在本地直接运行, local_sandboxed 代表本地运行但使用系统沙箱(bwrap/seatbelt)增强隔离, none 代表不启用。如果上传了 skills,选择 none 会导致其无法被 Agent 正常使用。"
},
"computer_use_require_admin": {
"description": "需要 AstrBot 管理员权限",
@@ -1089,6 +1089,12 @@
"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"
@@ -1316,6 +1322,10 @@
"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,6 +8,9 @@
"skills": "Skills",
"handlersOperation": "管理行为"
},
"titles": {
"installedAstrBotPlugins": "已安装的 AstrBot 插件"
},
"search": {
"placeholder": "搜索插件...",
"marketPlaceholder": "搜索市场插件..."
@@ -222,7 +225,7 @@
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
"runtimeNoneWarning": "Computer Use 运行环境为无,Skills 可能无法正确被 Agent 运行,因为没有启用运行环境。",
"runtimeHint": "需要在配置的 “使用电脑能力” 中将运行环境设置为 “local” 或 “sandbox” 才能让 AstrBot 正常使用你提供的 Skills。"
"runtimeHint": "需要在配置的 “使用电脑能力” 中将运行环境设置为 “local”、“local_sandboxed” 或 “sandbox” 才能让 AstrBot 正常使用你提供的 Skills。"
},
"card": {
"actions": {
@@ -50,24 +50,27 @@ let installLoading = ref(false);
const isDesktopReleaseMode = ref(
typeof window !== 'undefined' && !!window.astrbotDesktop?.isDesktop
);
const redirectConfirmDialog = ref(false);
const pendingRedirectUrl = ref('');
const resolvingReleaseTarget = ref(false);
const DEFAULT_ASTRBOT_RELEASE_BASE_URL = 'https://github.com/AstrBotDevs/AstrBot/releases';
const resolveReleaseBaseUrl = () => {
const raw = import.meta.env.VITE_ASTRBOT_RELEASE_BASE_URL;
// Keep upstream default on AstrBot releases; desktop distributors can override via env injection.
const normalized = raw?.trim()?.replace(/\/+$/, '') || '';
const withoutLatestSuffix = normalized.replace(/\/latest$/i, '');
return withoutLatestSuffix || DEFAULT_ASTRBOT_RELEASE_BASE_URL;
};
const releaseBaseUrl = resolveReleaseBaseUrl();
const getReleaseUrlByTag = (tag: string | null | undefined) => {
const normalizedTag = (tag || '').trim();
if (!normalizedTag || normalizedTag.toLowerCase() === 'latest') {
return `${releaseBaseUrl}/latest`;
const desktopUpdateDialog = ref(false);
const desktopUpdateChecking = ref(false);
const desktopUpdateInstalling = ref(false);
const desktopUpdateHasNewVersion = ref(false);
const desktopUpdateCurrentVersion = ref('-');
const desktopUpdateLatestVersion = ref('-');
const desktopUpdateStatus = ref('');
const getAppUpdaterBridge = (): AstrBotAppUpdaterBridge | null => {
if (typeof window === 'undefined') {
return null;
}
return `${releaseBaseUrl}/tag/${normalizedTag}`;
const bridge = window.astrbotAppUpdater;
if (
bridge &&
typeof bridge.checkForAppUpdate === 'function' &&
typeof bridge.installAppUpdate === 'function'
) {
return bridge;
}
return null;
};
const getSelectedGitHubProxy = () => {
@@ -89,16 +92,6 @@ const releasesHeader = computed(() => [
{ title: t('core.header.updateDialog.table.sourceUrl'), key: 'zipball_url' },
{ title: t('core.header.updateDialog.table.actions'), key: 'switch' }
]);
const latestReleaseTag = computed(() => {
const firstRelease = (releases.value as any[])?.[0];
if (firstRelease?.tag_name) {
return firstRelease.tag_name as string;
}
return hasNewVersion.value
? t('core.header.updateDialog.redirectConfirm.latestLabel')
: (botCurrVersion.value || '-');
});
// Form validation
const formValid = ref(true);
const passwordRules = computed(() => [
@@ -126,47 +119,88 @@ const accountEditStatus = ref({
message: ''
});
const open = (link: string) => {
window.open(link, '_blank');
};
function requestExternalRedirect(link: string) {
pendingRedirectUrl.value = link;
redirectConfirmDialog.value = true;
function cancelDesktopUpdate() {
if (desktopUpdateInstalling.value) {
return;
}
desktopUpdateDialog.value = false;
}
function cancelExternalRedirect() {
redirectConfirmDialog.value = false;
pendingRedirectUrl.value = '';
}
async function openDesktopUpdateDialog() {
desktopUpdateDialog.value = true;
desktopUpdateChecking.value = true;
desktopUpdateInstalling.value = false;
desktopUpdateHasNewVersion.value = false;
desktopUpdateCurrentVersion.value = '-';
desktopUpdateLatestVersion.value = '-';
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checking');
function confirmExternalRedirect() {
const targetUrl = pendingRedirectUrl.value;
cancelExternalRedirect();
if (targetUrl) {
open(targetUrl);
const bridge = getAppUpdaterBridge();
if (!bridge) {
desktopUpdateChecking.value = false;
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checkFailed');
return;
}
try {
const result = await bridge.checkForAppUpdate();
if (!result?.ok) {
desktopUpdateCurrentVersion.value = result?.currentVersion || '-';
desktopUpdateLatestVersion.value =
result?.latestVersion || result?.currentVersion || '-';
desktopUpdateStatus.value =
result?.reason || t('core.header.updateDialog.desktopApp.checkFailed');
return;
}
desktopUpdateCurrentVersion.value = result.currentVersion || '-';
desktopUpdateLatestVersion.value =
result.latestVersion || result.currentVersion || '-';
desktopUpdateHasNewVersion.value = !!result.hasUpdate;
desktopUpdateStatus.value = result.hasUpdate
? t('core.header.updateDialog.desktopApp.hasNewVersion')
: t('core.header.updateDialog.desktopApp.isLatest');
} catch (error) {
console.error(error);
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checkFailed');
} finally {
desktopUpdateChecking.value = false;
}
}
const getReleaseUrlForDesktop = () => {
const firstRelease = (releases.value as any[])?.[0];
if (firstRelease?.tag_name) {
return getReleaseUrlByTag(firstRelease.tag_name as string);
async function confirmDesktopUpdate() {
if (!desktopUpdateHasNewVersion.value || desktopUpdateInstalling.value) {
return;
}
if (hasNewVersion.value) return getReleaseUrlByTag('latest');
const tag = botCurrVersion.value?.startsWith('v') ? botCurrVersion.value : 'latest';
return getReleaseUrlByTag(tag);
};
const bridge = getAppUpdaterBridge();
if (!bridge) {
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installFailed');
return;
}
desktopUpdateInstalling.value = true;
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installing');
try {
const result = await bridge.installAppUpdate();
if (result?.ok) {
desktopUpdateDialog.value = false;
return;
}
desktopUpdateStatus.value =
result?.reason || t('core.header.updateDialog.desktopApp.installFailed');
} catch (error) {
console.error(error);
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installFailed');
} finally {
desktopUpdateInstalling.value = false;
}
}
function handleUpdateClick() {
if (isDesktopReleaseMode.value) {
requestExternalRedirect('');
resolvingReleaseTarget.value = true;
checkUpdate();
void getReleases().finally(() => {
pendingRedirectUrl.value = getReleaseUrlForDesktop() || getReleaseUrlByTag('latest');
resolvingReleaseTarget.value = false;
});
void openDesktopUpdateDialog();
return;
}
checkUpdate();
@@ -680,40 +714,38 @@ onMounted(async () => {
</v-card>
</v-dialog>
<v-dialog v-model="redirectConfirmDialog" max-width="460">
<v-dialog v-model="desktopUpdateDialog" max-width="460">
<v-card>
<v-card-title class="text-h3 pa-4 pl-6 pb-0">
{{ t('core.header.updateDialog.redirectConfirm.title') }}
{{ t('core.header.updateDialog.desktopApp.title') }}
</v-card-title>
<v-card-text>
<div class="mb-3">
{{ t('core.header.updateDialog.redirectConfirm.message') }}
{{ t('core.header.updateDialog.desktopApp.message') }}
</div>
<v-alert type="info" variant="tonal" density="compact">
<div>
{{ t('core.header.updateDialog.redirectConfirm.targetVersion') }}
<strong v-if="!resolvingReleaseTarget">{{ latestReleaseTag }}</strong>
<v-progress-circular v-else indeterminate size="16" width="2" class="ml-1" />
{{ t('core.header.updateDialog.desktopApp.currentVersion') }}
<strong>{{ desktopUpdateCurrentVersion }}</strong>
</div>
<div class="text-caption">
{{ t('core.header.updateDialog.redirectConfirm.currentVersion') }}
{{ botCurrVersion || '-' }}
<div>
{{ t('core.header.updateDialog.desktopApp.latestVersion') }}
<strong v-if="!desktopUpdateChecking">{{ desktopUpdateLatestVersion }}</strong>
<v-progress-circular v-else indeterminate size="16" width="2" class="ml-1" />
</div>
</v-alert>
<div class="text-caption mt-3">
<div>{{ t('core.header.updateDialog.redirectConfirm.guideTitle') }}</div>
<div>1. {{ t('core.header.updateDialog.redirectConfirm.guideStep1') }}</div>
<div>2. {{ t('core.header.updateDialog.redirectConfirm.guideStep2') }}</div>
<div>3. {{ t('core.header.updateDialog.redirectConfirm.guideStep3') }}</div>
{{ desktopUpdateStatus }}
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="cancelExternalRedirect">
<v-btn color="grey" variant="text" @click="cancelDesktopUpdate" :disabled="desktopUpdateInstalling">
{{ t('core.common.dialog.cancelButton') }}
</v-btn>
<v-btn color="primary" variant="flat" @click="confirmExternalRedirect"
:loading="resolvingReleaseTarget" :disabled="resolvingReleaseTarget || !pendingRedirectUrl">
<v-btn color="primary" variant="flat" @click="confirmDesktopUpdate"
:loading="desktopUpdateInstalling"
:disabled="desktopUpdateChecking || desktopUpdateInstalling || !desktopUpdateHasNewVersion">
{{ t('core.common.dialog.confirmButton') }}
</v-btn>
</v-card-actions>
@@ -1,7 +1,26 @@
export {};
declare global {
interface AstrBotDesktopAppUpdateCheckResult {
ok: boolean;
reason?: string | null;
currentVersion?: string;
latestVersion?: string | null;
hasUpdate: boolean;
}
interface AstrBotDesktopAppUpdateResult {
ok: boolean;
reason?: string | null;
}
interface AstrBotAppUpdaterBridge {
checkForAppUpdate: () => Promise<AstrBotDesktopAppUpdateCheckResult>;
installAppUpdate: () => Promise<AstrBotDesktopAppUpdateResult>;
}
interface Window {
astrbotAppUpdater?: AstrBotAppUpdaterBridge;
astrbotDesktop?: {
isDesktop: boolean;
isDesktopRuntime: () => Promise<boolean>;
+1
View File
@@ -61,6 +61,7 @@ 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";
}
File diff suppressed because it is too large Load Diff
+44 -3
View File
@@ -333,12 +333,53 @@ 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;
try {
await navigator.clipboard.writeText(createdApiKeyPlaintext.value);
const ok = await copyTextToClipboard(createdApiKeyPlaintext.value);
if (ok) {
showToast(tm('apiKey.messages.copySuccess'), 'success');
} catch (_) {
} else {
showToast(tm('apiKey.messages.copyFailed'), 'error');
}
};
@@ -0,0 +1,639 @@
<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>
@@ -0,0 +1,373 @@
<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
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.18.1"
version = "4.18.3"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.12"