Compare commits

...

15 Commits

Author SHA1 Message Date
Soulter
fa9897cacb chore: update subset 2026-04-30 22:17:32 +08:00
Soulter
1835467544 feat: re-implement plugin pinning functionality for extensions
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 22:16:47 +08:00
Weilong Liao
34dc91e4b0 perf: improve ui and supports edit skills file in webui (#7903)
* feat: update ExtensionCard variant to outlined and adjust InstalledPluginsTab layout for better responsiveness

* feat: update MCP servers management UI and add descriptions for better clarity

* feat: enhance OutlinedActionListItem component with clickable functionality and new slots

feat(i18n): update English, Russian, and Chinese translations for extension and knowledge base features

fix: improve DocumentDetail and KBDetail views with outlined card styles and remove unnecessary dividers

refactor: streamline KBList component to use OutlinedActionListItem for better UI consistency

style: adjust styles for knowledge base components and improve responsive design

test: add security tests for skill file browser and editor to prevent path traversal and file size issues

* feat: update UI components and styles for improved layout and readability
2026-04-30 22:11:15 +08:00
bugkeep
938c241799 fix: align OpenAI http_client with SDK httpx (#7773)
* fix: align OpenAI http_client with SDK httpx

* fix: narrow openai httpx import fallback
2026-04-30 10:53:34 +08:00
千岚之夏
71b6349b6a fix: stop_event() 后续 handler 仍然执行 (#7900)
* fix: check event.is_stopped() after handler execution in star_request.py

* fix: move is_stopped() check before clear_result(), add check in except block and loop start

* fix: remove redundant is_stopped() check after stop_event() in except block

---------

Co-authored-by: Blueteemo <Blueteemo@users.noreply.github.com>
2026-04-30 09:24:33 +08:00
Weilong Liao
7c185f8e40 feat: add PluginDetailPage component for detailed plugin information display (#7896)
* feat: add PluginDetailPage component for detailed plugin information display

refactor: remove extension preference storage management and related tests

chore: clean up useExtensionPage by removing unused preference storage logic

* feat: add getHandlerDisplayName function for improved handler name display
2026-04-29 22:42:02 +08:00
wanger
6756a669d7 fix(dashboard): use v-autocomplete for list+options config field (#7884) (#7885)
* fix(dashboard): use v-autocomplete for list+options config field (#7884)

Replace v-select with v-autocomplete in the list+options branch of
ConfigItemRenderer. v-select's keyboard typeahead auto-toggles the
first prefix-matching item in multiple mode, which is unusable for
long option lists (e.g. plugin language pickers). v-autocomplete
filters the dropdown by typed text instead.

Bind v-model:search and clear it in @update:model-value so the search
box resets after each selection, allowing consecutive keyword search.

* perf(dashboard): memoize list config select items via computed

Wrap getSelectItems(itemMeta) in a computed so the options array
is only re-mapped when itemMeta changes, not on every keystroke
in the v-autocomplete search input. Avoids quadratic-ish work for
long option lists

---------

Co-authored-by: wanger <wanger@example.com>
2026-04-29 18:42:52 +08:00
s11IM
587286a967 fix: warn when default chat provider is unset (#7498)
* fix: warn when default chat provider is unset

* fix: align startup warning with provider fallback

* refactor: simplify default chat provider warning guard checks

* feat: warn when default chat provider id is invalid or missing

 - Emit a warning when `default_provider_id` points to a
 non-existent enabled provider, preventing silent fallback to
 an unexpected model.
 - Reset the warning guard before each
 `provider_manager.initialize()` so configuration reloads
 trigger a fresh re-evaluation.
 - Harden guard checks to handle `None` `provider_settings` and
 `None` provider IDs gracefully.

* test: cover fallback and invalid default provider id warnings

 - Add case for `curr_provider_inst=None` to verify fallback to
 `providers[0]`.
 - Add case for a `default_provider_id` that does not match any enabled
 provider.

* style: format default chat provider warning

---------

Co-authored-by: RC-CHN <1051989940@qq.com>
2026-04-29 11:05:38 +08:00
Ruochen Pan
eb69bf3687 fix(shipyard-neo): add readiness gate and graceful sandbox cleanup (#7881)
* fix(shipyard-neo): add readiness gate and graceful sandbox cleanup

* fix:  Add **kwargs to ComputerBooter.shutdown()

* test(shipyard-neo): add tests for readiness gate and shutdown behavior
2026-04-29 10:26:20 +08:00
Soulter
6b36e1abac fix: comment out tool_choice parameter in ToolLoopAgentRunner for debugging
fixes: #7853
closes: #7856
closes: #7862
2026-04-29 00:20:38 +08:00
Weilong Liao
8f356b84c7 fix(core): restrict send_message_to_user to current session (security fix #7822) (#7824)
* fix(core): security fix - restrict send_message_to_user to current session only

Closes #7822

SECURITY: Remove the user-controlled 'session' parameter from the
send_message_to_user tool. Previously, a regular user could ask the
LLM to send messages to any arbitrary session (group chat) by
providing a crafted session string, which is a high-risk
vulnerability.

Changes:
- Remove 'session' parameter from tool schema (LLM can no longer
  propose it)
- Always use context.context.event.unified_msg_origin as the target
  session
- Update description to clearly state that messages can only be sent
  to the current user's session

* fix: restore session param but restrict to admin only

- Re-add the  parameter removed in the original PR
- Non-admin users can only send to their own session (current_session)
- Admin users can send to any session via the  param
- Uses  from computer_tools.util (same pattern as fs.py)
- Ref: https://github.com/AstrBotDevs/AstrBot/issues/7822

Co-authored-by: Soulter <soulter@astrbot.app>

* Update message_tools.py

---------

Co-authored-by: AstrBot <bot@astrbot.app>
2026-04-29 00:15:16 +08:00
诗浓
98b05b7e89 fix(provider): persist model enable toggle (#7865)
* fix(provider): persist model enable toggle

Fixes AstrBotDevs/AstrBot#7863

* fix(provider): wait for model toggle refresh
2026-04-28 23:55:46 +08:00
Soulter
962c299c2d feat(shell): enhance exec method to support timeout parameter and improve background command handling 2026-04-28 23:55:29 +08:00
daniel5u
66d620dab5 fix: merge anthropic parallel tool results (#7875) 2026-04-28 23:48:09 +08:00
Weilong Liao
ac7f6aa60d feat(shell): add background command execution with output redirection and timeout support (#7835)
* feat(shell): add background command execution with output redirection and timeout support

* feat(shell): update timeout parameter to be optional in shell execution methods

* feat(shell): set default timeout for shell execution to 10,000,000 milliseconds

* feat(shell): set default timeout to 300s for shell execution

* feat(shell): reorder timeout parameter in ExecuteShellTool configuration

* feat(shell): implement background command execution with detached shell command support

Co-authored-by: Copilot <copilot@github.com>

* test(shell): remove obsolete test for background shell command output redirection

* fix: reorder import statements in shell.py for consistency

* fix: wrap command in parentheses for background output redirection

---------

Co-authored-by: Copilot <copilot@github.com>
2026-04-28 23:25:54 +08:00
75 changed files with 4559 additions and 2191 deletions

View File

@@ -1,10 +1,20 @@
import copy
import traceback
from sys import maxsize
import astrbot.api.message_components as Comp
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.message_components import Image, Plain
from astrbot.api.provider import LLMResponse, ProviderRequest
from astrbot.core import logger
from astrbot.core.utils.session_waiter import (
FILTERS,
USER_SESSIONS,
SessionController,
SessionWaiter,
session_waiter,
)
from .long_term_memory import LongTermMemory
@@ -18,6 +28,103 @@ class Main(star.Star):
except BaseException as e:
logger.error(f"聊天增强 err: {e}")
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
async def handle_session_control_agent(self, event: AstrMessageEvent) -> None:
"""会话控制代理"""
for session_filter in FILTERS:
session_id = session_filter.filter(event)
if session_id in USER_SESSIONS:
await SessionWaiter.trigger(session_id, event)
event.stop_event()
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1)
async def handle_empty_mention(self, event: AstrMessageEvent):
"""处理只有一个 @ 或仅有唤醒前缀的消息,并等待用户下一条内容。"""
try:
messages = event.get_messages()
cfg = self.context.get_config(umo=event.unified_msg_origin)
p_settings = cfg["platform_settings"]
wake_prefix = cfg.get("wake_prefix", [])
if len(messages) != 1:
return
is_empty_mention = (
isinstance(messages[0], Comp.At)
and str(messages[0].qq) == str(event.get_self_id())
and p_settings.get("empty_mention_waiting", True)
)
is_wake_prefix_only = (
isinstance(messages[0], Comp.Plain)
and messages[0].text.strip() in wake_prefix
)
if not (is_empty_mention or is_wake_prefix_only):
return
if p_settings.get("empty_mention_waiting_need_reply", True):
try:
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin,
)
conversation = None
if curr_cid:
conversation = (
await self.context.conversation_manager.get_conversation(
event.unified_msg_origin,
curr_cid,
)
)
else:
curr_cid = (
await self.context.conversation_manager.new_conversation(
event.unified_msg_origin,
platform_id=event.get_platform_id(),
)
)
yield event.request_llm(
prompt=(
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
),
session_id=curr_cid,
contexts=[],
system_prompt="",
conversation=conversation,
)
except Exception as e:
logger.error(f"LLM response failed: {e!s}")
yield event.plain_result("想要问什么呢?😄")
@session_waiter(60)
async def empty_mention_waiter(
controller: SessionController,
event: AstrMessageEvent,
) -> None:
if not event.message_str or not event.message_str.strip():
return
event.message_obj.message.insert(
0,
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
)
new_event = copy.copy(event)
self.context.get_event_queue().put_nowait(new_event)
event.stop_event()
controller.stop()
try:
await empty_mention_waiter(event)
except TimeoutError:
pass
except Exception as e:
yield event.plain_result("发生错误,请联系管理员: " + str(e))
finally:
event.stop_event()
except Exception as e:
logger.error("handle_empty_mention error: " + str(e))
def ltm_enabled(self, event: AstrMessageEvent):
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
"provider_ltm_settings"

View File

@@ -1,4 +1,4 @@
name: astrbot
desc: AstrBot 自带插件,包含人格注入、思考内容注入、群聊上下文感知等功能的实现,禁用后将无法使用这些功能。
author: Soulter
version: 4.1.0
desc: AstrBot's internal plugin, providing some basic capabilities.
author: AstrBot Team
version: 4.1.0

View File

@@ -1,4 +1,4 @@
name: builtin_commands
desc: AstrBot 自带指令,提供常用的对话管理、工具使用、插件管理等功能。
desc: AstrBot's internal plugin, providing all built-in commands such as /reset.
author: Soulter
version: 0.0.1

View File

@@ -1,115 +0,0 @@
import copy
from sys import maxsize
import astrbot.api.message_components as Comp
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.star import Context, Star
from astrbot.core.utils.session_waiter import (
FILTERS,
USER_SESSIONS,
SessionController,
SessionWaiter,
session_waiter,
)
class Main(Star):
"""会话控制"""
def __init__(self, context: Context) -> None:
super().__init__(context)
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
async def handle_session_control_agent(self, event: AstrMessageEvent) -> None:
"""会话控制代理"""
for session_filter in FILTERS:
session_id = session_filter.filter(event)
if session_id in USER_SESSIONS:
await SessionWaiter.trigger(session_id, event)
event.stop_event()
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1)
async def handle_empty_mention(self, event: AstrMessageEvent):
"""实现了对只有一个 @ 的消息内容的处理"""
try:
messages = event.get_messages()
cfg = self.context.get_config(umo=event.unified_msg_origin)
p_settings = cfg["platform_settings"]
wake_prefix = cfg.get("wake_prefix", [])
if len(messages) == 1:
if (
isinstance(messages[0], Comp.At)
and str(messages[0].qq) == str(event.get_self_id())
and p_settings.get("empty_mention_waiting", True)
) or (
isinstance(messages[0], Comp.Plain)
and messages[0].text.strip() in wake_prefix
):
if p_settings.get("empty_mention_waiting_need_reply", True):
try:
# 尝试使用 LLM 生成更生动的回复
# func_tools_mgr = self.context.get_llm_tool_manager()
# 获取用户当前的对话信息
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin,
)
conversation = None
if curr_cid:
conversation = await self.context.conversation_manager.get_conversation(
event.unified_msg_origin,
curr_cid,
)
else:
# 创建新对话
curr_cid = await self.context.conversation_manager.new_conversation(
event.unified_msg_origin,
platform_id=event.get_platform_id(),
)
# 使用 LLM 生成回复
yield event.request_llm(
prompt=(
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
),
session_id=curr_cid,
contexts=[],
system_prompt="",
conversation=conversation,
)
except Exception as e:
logger.error(f"LLM response failed: {e!s}")
# LLM 回复失败,使用原始预设回复
yield event.plain_result("想要问什么呢?😄")
@session_waiter(60)
async def empty_mention_waiter(
controller: SessionController,
event: AstrMessageEvent,
) -> None:
if not event.message_str or not event.message_str.strip():
return
event.message_obj.message.insert(
0,
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
)
new_event = copy.copy(event)
# 重新推入事件队列
self.context.get_event_queue().put_nowait(new_event)
event.stop_event()
controller.stop()
try:
await empty_mention_waiter(event)
except TimeoutError as _:
pass
except Exception as e:
yield event.plain_result("发生错误,请联系管理员: " + str(e))
finally:
event.stop_event()
except Exception as e:
logger.error("handle_empty_mention error: " + str(e))

View File

@@ -1,5 +0,0 @@
name: session_controller
desc: 为插件支持会话控制
author: Cvandia & Soulter
version: v1.0.1
repo: https://astrbot.app

View File

@@ -1293,7 +1293,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
model=self.req.model,
session_id=self.req.session_id,
extra_user_content_parts=self.req.extra_user_content_parts,
tool_choice="required",
# tool_choice="required",
abort_signal=self._abort_signal,
)
if requery_resp:
@@ -1319,7 +1319,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
model=self.req.model,
session_id=self.req.session_id,
extra_user_content_parts=self.req.extra_user_content_parts,
tool_choice="required",
# tool_choice="required",
abort_signal=self._abort_signal,
)
if repair_resp:

View File

@@ -36,7 +36,15 @@ class ComputerBooter:
async def boot(self, session_id: str) -> None: ...
async def shutdown(self) -> None: ...
async def shutdown(self, **kwargs) -> None:
"""Shut down the computer sandbox.
Subclasses may accept extra keyword arguments for
type-specific cleanup (e.g. ``delete_sandbox`` for
ShipyardNeoBooter). The default implementation ignores
them.
"""
...
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to the computer.

View File

@@ -90,7 +90,7 @@ class LocalShellComponent(ShellComponent):
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 30,
timeout: int | None = 300,
shell: bool = True,
background: bool = False,
) -> dict[str, Any]:
@@ -123,7 +123,7 @@ class LocalShellComponent(ShellComponent):
shell=shell,
cwd=working_dir,
env=run_env,
timeout=timeout,
timeout=timeout or 300,
capture_output=True,
)
return {

View File

@@ -0,0 +1,18 @@
import shlex
_BACKGROUND_SPAWN_SCRIPT = (
"import subprocess, sys; "
"p = subprocess.Popen("
"['bash', '-lc', sys.argv[1]], "
"stdin=subprocess.DEVNULL, "
"stdout=subprocess.DEVNULL, "
"stderr=subprocess.DEVNULL, "
"start_new_session=True, "
"close_fds=True"
"); "
"print(p.pid)"
)
def build_detached_shell_command(command: str) -> str:
return f"python3 -c {shlex.quote(_BACKGROUND_SPAWN_SCRIPT)} {shlex.quote(command)}"

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import shlex
from typing import Any
from shipyard import FileSystemComponent as ShipyardFileSystemComponent
@@ -9,9 +10,93 @@ from astrbot.api import logger
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
from .shell_background import build_detached_shell_command
from .shipyard_search_file_util import search_files_via_shell
def _maybe_model_dump(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if hasattr(value, "model_dump"):
dumped = value.model_dump()
if isinstance(dumped, dict):
return dumped
return {}
class ShipyardShellWrapper:
def __init__(self, _shipyard_shell: ShellComponent):
self._shell = _shipyard_shell
async def exec(
self,
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 300,
shell: bool = True,
background: bool = False,
) -> dict[str, Any]:
if not shell:
return {
"stdout": "",
"stderr": "error: only shell mode is supported in shipyard booter.",
"exit_code": 2,
"success": False,
}
run_command = command
if env:
env_prefix = " ".join(
f"{k}={shlex.quote(str(v))}" for k, v in sorted(env.items())
)
run_command = f"{env_prefix} {run_command}"
if background:
run_command = build_detached_shell_command(run_command)
result = await self._shell.exec(
run_command,
timeout=timeout or 300,
cwd=cwd,
)
payload = _maybe_model_dump(result)
stdout = payload.get("output", payload.get("stdout", "")) or ""
stderr = payload.get("error", payload.get("stderr", "")) or ""
exit_code = payload.get("exit_code")
if background:
pid: int | None = None
try:
pid = int(str(stdout).strip().splitlines()[-1])
except Exception:
pid = None
return {
"pid": pid,
"stdout": (
f"Command is running in the background. pid={pid}"
if pid is not None
else "Command was submitted in the background."
),
"stderr": stderr,
"exit_code": exit_code,
"success": bool(payload.get("success", not stderr)),
"execution_id": payload.get("execution_id"),
"execution_time_ms": payload.get("execution_time_ms"),
"command": payload.get("command"),
}
return {
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"success": bool(payload.get("success", not stderr)),
"execution_id": payload.get("execution_id"),
"execution_time_ms": payload.get("execution_time_ms"),
"command": payload.get("command"),
}
class ShipyardFileSystemWrapper:
def __init__(
self, _shipyard_fs: ShipyardFileSystemComponent, _shipyard_shell: ShellComponent
@@ -107,7 +192,8 @@ class ShipyardBooter(ComputerBooter):
)
logger.info(f"Got sandbox ship: {ship.id} for session: {session_id}")
self._ship = ship
self._fs = ShipyardFileSystemWrapper(self._ship.fs, self._ship.shell)
self._shell = ShipyardShellWrapper(self._ship.shell)
self._fs = ShipyardFileSystemWrapper(self._ship.fs, self._shell)
async def shutdown(self) -> None:
logger.info("[Computer] Shipyard booter shutdown.")
@@ -122,7 +208,7 @@ class ShipyardBooter(ComputerBooter):
@property
def shell(self) -> ShellComponent:
return self._ship.shell
return self._shell
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to sandbox"""

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import os
import shlex
from typing import Any, cast
@@ -13,6 +14,7 @@ from ..olayer import (
ShellComponent,
)
from .base import ComputerBooter
from .shell_background import build_detached_shell_command
from .shipyard_search_file_util import search_files_via_shell
try:
@@ -96,7 +98,7 @@ class NeoShellComponent(ShellComponent):
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 30,
timeout: int | None = 300,
shell: bool = True,
background: bool = False,
) -> dict[str, Any]:
@@ -116,11 +118,11 @@ class NeoShellComponent(ShellComponent):
run_command = f"{env_prefix} {run_command}"
if background:
run_command = f"nohup sh -lc {shlex.quote(run_command)} >/tmp/astrbot_bg.log 2>&1 & echo $!"
run_command = build_detached_shell_command(run_command)
result = await self._sandbox.shell.exec(
run_command,
timeout=timeout or 30,
timeout=timeout or 300,
cwd=cwd,
)
payload = _maybe_model_dump(result)
@@ -136,7 +138,11 @@ class NeoShellComponent(ShellComponent):
pid = None
return {
"pid": pid,
"stdout": stdout,
"stdout": (
f"Command is running in the background. pid={pid}"
if pid is not None
else "Command was submitted in the background."
),
"stderr": stderr,
"exit_code": exit_code,
"success": bool(payload.get("success", not stderr)),
@@ -433,6 +439,9 @@ class ShipyardNeoBooter(ComputerBooter):
ttl=self._ttl,
)
# --- Readiness gate: wait until sandbox session is READY ---
await self._wait_until_ready(self._sandbox)
self._shell = NeoShellComponent(self._sandbox)
self._fs = NeoFileSystemComponent(self._sandbox, self._shell)
self._python = NeoPythonComponent(self._sandbox)
@@ -450,6 +459,78 @@ class ShipyardNeoBooter(ComputerBooter):
bool(self._bay_manager),
)
async def _wait_until_ready(self, sandbox: Sandbox) -> None:
"""Poll sandbox status until READY, or raise on FAILED / timeout.
Covers both warm-pool hits (near-instant) and cold starts (up to 180s).
On FAILED, EXPIRED, or timeout the sandbox is deleted before raising
so no orphan resources leak on Bay.
"""
READINESS_TIMEOUT = 180 # seconds
POLL_INTERVAL = 2 # seconds
sandbox_id = sandbox.id
deadline = asyncio.get_running_loop().time() + READINESS_TIMEOUT
while True:
await sandbox.refresh()
status = getattr(sandbox.status, "value", str(sandbox.status))
if status == "ready":
logger.info(
"[Computer] Sandbox %s is ready (profile=%s)",
sandbox_id,
sandbox.profile,
)
return
if status in {"failed", "expired"}:
logger.error(
"[Computer] Sandbox %s reached terminal state: %s",
sandbox_id,
status,
)
try:
await sandbox.delete()
except Exception as del_err:
logger.warning(
"[Computer] Failed to delete failed sandbox %s: %s",
sandbox_id,
del_err,
)
raise RuntimeError(
f"Sandbox {sandbox_id} is in terminal state: {status}"
)
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
logger.error(
"[Computer] Sandbox %s did not become ready within %ds "
"(last status: %s)",
sandbox_id,
READINESS_TIMEOUT,
status,
)
try:
await sandbox.delete()
except Exception as del_err:
logger.warning(
"[Computer] Failed to delete timed-out sandbox %s: %s",
sandbox_id,
del_err,
)
raise TimeoutError(
f"Sandbox {sandbox_id} did not become ready within "
f"{READINESS_TIMEOUT}s (last status: {status})"
)
logger.debug(
"[Computer] Sandbox %s status=%s, waiting...",
sandbox_id,
status,
)
await asyncio.sleep(POLL_INTERVAL)
async def _resolve_profile(self, client: Any) -> str:
"""Pick the best profile for this session.
@@ -505,16 +586,41 @@ class ShipyardNeoBooter(ComputerBooter):
return chosen
async def shutdown(self) -> None:
async def shutdown(self, *, delete_sandbox: bool = False) -> None:
if self._client is not None:
sandbox_id = getattr(self._sandbox, "id", "unknown")
# Delete sandbox on Bay BEFORE closing the HTTP client.
# This is critical for cleanup — calling delete after
# __aexit__ would fail because the httpx session is already
# torn down.
if delete_sandbox and self._sandbox is not None:
try:
logger.info(
"[Computer] Deleting Shipyard Neo sandbox: id=%s", sandbox_id
)
await self._sandbox.delete()
logger.info(
"[Computer] Shipyard Neo sandbox deleted: id=%s", sandbox_id
)
except Exception as e:
logger.warning(
"[Computer] Failed to delete sandbox %s (may already be "
"cleaned up by Bay GC): %s",
sandbox_id,
e,
)
logger.info(
"[Computer] Shutting down Shipyard Neo sandbox: id=%s", sandbox_id
"[Computer] Shutting down Shipyard Neo sandbox client: id=%s",
sandbox_id,
)
await self._client.__aexit__(None, None, None)
self._client = None
self._sandbox = None
logger.info("[Computer] Shipyard Neo sandbox shut down: id=%s", sandbox_id)
logger.info(
"[Computer] Shipyard Neo sandbox client shut down: id=%s", sandbox_id
)
# NOTE: We intentionally do NOT stop the Bay container here.
# It stays running for reuse by future sessions. The user can

View File

@@ -445,7 +445,22 @@ async def get_booter(
if session_id in session_booter:
booter = session_booter[session_id]
if not await booter.available():
# rebuild
# Clean up old booter before rebuilding so sandbox resources
# on Bay (containers, volumes, networks) are not leaked.
# Only ShipyardNeoBooter supports delete_sandbox; other booters
# (local, boxlite, cua, etc.) are not backed by a remote sandbox
# manager and don't need it.
try:
if booter_type == "shipyard_neo":
await booter.shutdown(delete_sandbox=True)
else:
await booter.shutdown()
except Exception as shutdown_err:
logger.warning(
"[Computer] Error shutting down stale booter for session %s: %s",
session_id,
shutdown_err,
)
session_booter.pop(session_id, None)
if session_id not in session_booter:
uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
@@ -509,7 +524,10 @@ async def get_booter(
except Exception as e:
logger.error(f"Error booting sandbox for session {session_id}: {e}")
try:
await client.shutdown()
if booter_type == "shipyard_neo":
await client.shutdown(delete_sandbox=True)
else:
await client.shutdown()
except Exception as shutdown_error:
logger.warning(
"Failed to shutdown sandbox after boot error for session %s: %s",

View File

@@ -13,7 +13,7 @@ class ShellComponent(Protocol):
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 30,
timeout: int | None = 300,
shell: bool = True,
background: bool = False,
) -> dict[str, Any]:

View File

@@ -3669,11 +3669,6 @@ CONFIG_METADATA_3 = {
"type": "string",
"hint": "如果唤醒前缀为 /, 额外聊天唤醒前缀为 chat则需要 /chat 才会触发 LLM 请求",
},
"provider_settings.prompt_prefix": {
"description": "用户提示词",
"type": "string",
"hint": "可使用 {{prompt}} 作为用户输入的占位符。如果不输入占位符则代表添加在用户输入的前面。",
},
"provider_settings.image_compress_enabled": {
"description": "启用图片压缩",
"type": "bool",
@@ -3697,6 +3692,12 @@ CONFIG_METADATA_3 = {
},
"slider": {"min": 1, "max": 100, "step": 1},
},
"provider_settings.prompt_prefix": {
"description": "用户提示词",
"type": "string",
"hint": "可使用 {{prompt}} 作为用户输入的占位符。如果不输入占位符则代表添加在用户输入的前面。",
"collapsed": True,
},
"provider_tts_settings.dual_output": {
"description": "开启 TTS 时同时输出语音和文字内容",
"type": "bool",

View File

@@ -59,6 +59,7 @@ class AstrBotCoreLifecycle:
self.subagent_orchestrator: SubAgentOrchestrator | None = None
self.cron_manager: CronJobManager | None = None
self.temp_dir_cleaner: TempDirCleaner | None = None
self._default_chat_provider_warning_emitted = False
# 设置代理
proxy_config = self.astrbot_config.get("http_proxy", "")
@@ -97,6 +98,47 @@ class AstrBotCoreLifecycle:
except Exception as e:
logger.error(f"Subagent orchestrator init failed: {e}", exc_info=True)
def _warn_about_unset_default_chat_provider(self) -> None:
if self._default_chat_provider_warning_emitted:
return
pm = getattr(self, "provider_manager", None)
if not pm:
return
providers = pm.provider_insts
if len(providers) == 0:
return
provider_settings = getattr(pm, "provider_settings", None) or {}
default_id = provider_settings.get("default_provider_id")
fallback = pm.curr_provider_inst or providers[0]
fallback_id = fallback.provider_config.get("id") or "unknown"
if not default_id:
if len(providers) <= 1:
return
self._default_chat_provider_warning_emitted = True
logger.warning(
"Detected %d enabled chat providers but `provider_settings.default_provider_id` is empty. "
"AstrBot will use `%s` as the startup fallback chat provider. "
"Set a default chat model in the WebUI configuration page to avoid unexpected provider switching.",
len(providers),
fallback_id,
)
return
found = any((p.provider_config.get("id") == default_id) for p in providers)
if not found:
self._default_chat_provider_warning_emitted = True
logger.warning(
"Configured `default_provider_id` is `%s` but no enabled provider matches that ID. "
"AstrBot will use `%s` as the fallback chat provider. "
"Please check the WebUI configuration page.",
default_id,
fallback_id,
)
async def initialize(self) -> None:
"""初始化 AstrBot 核心生命周期管理类.
@@ -201,7 +243,9 @@ class AstrBotCoreLifecycle:
await self.plugin_manager.reload()
# 根据配置实例化各个 Provider
self._default_chat_provider_warning_emitted = False
await self.provider_manager.initialize()
self._warn_about_unset_default_chat_provider()
await self.kb_manager.initialize()

View File

@@ -34,6 +34,8 @@ class StarRequestSubStage(Stage):
handlers_parsed_params = {}
for handler in activated_handlers:
if event.is_stopped():
break
params = handlers_parsed_params.get(handler.handler_full_name, {})
md = star_map.get(handler.handler_module_path)
if not md:
@@ -46,6 +48,8 @@ class StarRequestSubStage(Stage):
wrapper = call_handler(event, handler.handler, **params)
async for ret in wrapper:
yield ret
if event.is_stopped():
break
event.clear_result() # 清除上一个 handler 的结果
except Exception as e:
traceback_text = traceback.format_exc()

View File

@@ -195,18 +195,37 @@ class ProviderAnthropic(Provider):
},
)
elif message["role"] == "tool":
new_messages.append(
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": message["tool_call_id"],
"content": message["content"] or "<empty response>",
},
],
},
tool_result_block = {
"type": "tool_result",
"tool_use_id": message["tool_call_id"],
"content": message["content"] or "<empty response>",
}
last_message = new_messages[-1] if new_messages else None
last_content = (
last_message.get("content")
if isinstance(last_message, dict)
else None
)
can_append_to_previous_tool_results = (
last_message is not None
and last_message.get("role") == "user"
and isinstance(last_content, list)
and len(last_content) > 0
and all(
isinstance(block, dict) and block.get("type") == "tool_result"
for block in last_content
)
)
if can_append_to_previous_tool_results:
last_content.append(tool_result_block)
else:
new_messages.append(
{
"role": "user",
"content": [tool_result_block],
},
)
elif message["role"] == "user":
if isinstance(message.get("content"), list):
converted_content = []

View File

@@ -441,7 +441,14 @@ class ProviderOpenAIOfficial(Provider):
def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient:
"""创建带代理的 HTTP 客户端"""
proxy = provider_config.get("proxy", "")
return create_proxy_client("OpenAI", proxy)
httpx_module: Any = httpx
try:
from openai import _base_client as openai_base_client
httpx_module = getattr(openai_base_client, "httpx", httpx)
except ImportError:
pass
return create_proxy_client("OpenAI", proxy, httpx_module=httpx_module)
def __init__(self, provider_config, provider_settings) -> None:
super().__init__(provider_config, provider_settings)

View File

@@ -1,6 +1,9 @@
import json
import os
import shlex
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from astrbot.api import FunctionTool
@@ -8,6 +11,7 @@ from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter
from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path
from ..registry import builtin_tool
from .util import check_admin_permission, is_local_runtime, workspace_root
@@ -17,6 +21,32 @@ _COMPUTER_RUNTIME_TOOL_CONFIG = {
}
def _quote_redirect_path(path: str, *, local_runtime: bool) -> str:
if local_runtime and os.name == "nt":
escaped_path = path.replace('"', '""')
else:
escaped_path = path.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped_path}"'
def _build_background_output_path(*, local_runtime: bool) -> str:
file_name = f"astrbot_shell_stdout_{uuid.uuid4().hex[:8]}.log"
if local_runtime:
output_dir = Path(get_astrbot_system_tmp_path()) / "shell"
output_dir.mkdir(parents=True, exist_ok=True)
return str((output_dir / file_name).resolve(strict=False))
return f"/tmp/{file_name}"
def _redirect_background_stdout_command(
command: str,
*,
output_path: str,
local_runtime: bool,
) -> str:
return f"({command}) > {_quote_redirect_path(output_path, local_runtime=local_runtime)} 2>&1"
@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG)
@dataclass
class ExecuteShellTool(FunctionTool):
@@ -32,12 +62,17 @@ class ExecuteShellTool(FunctionTool):
},
"background": {
"type": "boolean",
"description": "Whether to run the command in the background.",
"description": "Run the command in the background. Use the file read tool to read the output later. For long running commands, using this option.",
"default": False,
},
"timeout": {
"type": "integer",
"description": "Optional timeout in seconds for the command execution.",
"default": 300,
},
"env": {
"type": "object",
"description": "Optional environment variables to set for the file creation process.",
"description": "Optional environment variables to set.",
"additionalProperties": {"type": "string"},
"default": {},
},
@@ -51,6 +86,7 @@ class ExecuteShellTool(FunctionTool):
context: ContextWrapper[AstrAgentContext],
command: str,
background: bool = False,
timeout: int | None = 300,
env: dict[str, Any] | None = None,
) -> ToolExecResult:
if permission_error := check_admin_permission(context, "Shell execution"):
@@ -71,12 +107,31 @@ class ExecuteShellTool(FunctionTool):
env = dict(env or {})
effective_background = background and not _is_self_detached_command(command)
stdout_file: str | None = None
if effective_background:
local_runtime = is_local_runtime(context)
stdout_file = _build_background_output_path(
local_runtime=local_runtime,
)
command = _redirect_background_stdout_command(
command,
output_path=stdout_file,
local_runtime=local_runtime,
)
result = await sb.shell.exec(
command,
cwd=cwd,
background=effective_background,
env=env,
timeout=timeout or 300,
)
if stdout_file:
result["stdout"] = (
f"Command is running in the background. stdout/stderr is being "
f"written to `{stdout_file}`. Use astrbot_file_read_tool to read it."
)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
detail = str(e) or type(e).__name__

View File

@@ -14,6 +14,7 @@ from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.tools.computer_tools.util import check_admin_permission
from astrbot.core.tools.registry import builtin_tool
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
@@ -117,7 +118,16 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
session = kwargs.get("session") or context.context.event.unified_msg_origin
# Security: only AstrBot admins can send messages to other sessions.
# Non-admin users are always restricted to their own session.
# See https://github.com/AstrBotDevs/AstrBot/issues/7822
current_session = context.context.event.unified_msg_origin
session = kwargs.get("session") or current_session
if session != current_session:
if permission_error := check_admin_permission(
context, "Send message to another session"
):
return permission_error
messages = kwargs.get("messages")
if not isinstance(messages, list) or not messages:
return "error: messages parameter is empty or invalid."

View File

@@ -1,6 +1,7 @@
"""Network error handling utilities for providers."""
import ssl
from typing import Any
import httpx
@@ -90,6 +91,7 @@ def create_proxy_client(
proxy: str | None = None,
headers: dict[str, str] | None = None,
verify: ssl.SSLContext | str | bool | None = None,
httpx_module: Any = httpx,
) -> httpx.AsyncClient:
"""Create an httpx AsyncClient with proxy configuration if provided.
@@ -104,8 +106,11 @@ def create_proxy_client(
provider_label: The provider name for log prefix (e.g., "OpenAI", "Gemini")
proxy: The proxy address (e.g., "http://127.0.0.1:7890"), or None/empty
headers: Optional custom headers to include in every request
verify: Optional override for TLS verification. Defaults to the hybrid
SSL context (system store + certifi) when not provided.
verify: Optional override for TLS verification. Defaults to the shared
system SSL context when not provided.
httpx_module: Optional httpx module to construct AsyncClient from. This is
useful when a provider SDK performs isinstance checks against its own
httpx import.
Returns:
An httpx.AsyncClient created with the hybrid SSL context (system store + certifi); the proxy is applied only if one is provided.
@@ -113,5 +118,7 @@ def create_proxy_client(
resolved_verify = _SYSTEM_SSL_CTX if verify is None else verify
if proxy:
logger.info(f"[{provider_label}] 使用代理: {proxy}")
return httpx.AsyncClient(proxy=proxy, verify=resolved_verify, headers=headers)
return httpx.AsyncClient(verify=resolved_verify, headers=headers)
return httpx_module.AsyncClient(
proxy=proxy, verify=resolved_verify, headers=headers
)
return httpx_module.AsyncClient(verify=resolved_verify, headers=headers)

View File

@@ -41,6 +41,23 @@ def _to_bool(value: Any, default: bool = False) -> bool:
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
_SKILL_FILE_MAX_BYTES = 512 * 1024
_EDITABLE_SKILL_FILE_SUFFIXES = {
".css",
".html",
".ini",
".js",
".json",
".md",
".py",
".sh",
".toml",
".ts",
".txt",
".yaml",
".yml",
}
_EDITABLE_SKILL_FILENAMES = {"Dockerfile", "Makefile"}
def _next_available_temp_path(temp_dir: str, filename: str) -> str:
@@ -63,6 +80,11 @@ class SkillsRoute(Route):
"/skills/upload": ("POST", self.upload_skill),
"/skills/batch-upload": ("POST", self.batch_upload_skills),
"/skills/download": ("GET", self.download_skill),
"/skills/files": ("GET", self.list_skill_files),
"/skills/file": [
("GET", self.get_skill_file),
("POST", self.update_skill_file),
],
"/skills/update": ("POST", self.update_skill),
"/skills/delete": ("POST", self.delete_skill),
"/skills/neo/candidates": ("GET", self.get_neo_candidates),
@@ -77,6 +99,75 @@ class SkillsRoute(Route):
}
self.register_routes()
def _resolve_local_skill_dir(self, name: str) -> Path:
skill_name = str(name or "").strip()
if not skill_name:
raise ValueError("Missing skill name")
if not _SKILL_NAME_RE.match(skill_name):
raise ValueError("Invalid skill name")
skill_mgr = SkillManager()
if skill_mgr.is_sandbox_only_skill(skill_name):
raise PermissionError(
"Sandbox preset skill cannot be opened from local skill files."
)
skills_root = Path(skill_mgr.skills_root).resolve(strict=True)
skill_dir = (skills_root / skill_name).resolve(strict=True)
if not skill_dir.is_relative_to(skills_root):
raise PermissionError("Invalid skill path")
if not skill_dir.is_dir() or not (skill_dir / "SKILL.md").exists():
raise FileNotFoundError("Local skill not found")
return skill_dir
def _resolve_skill_relative_path(
self,
skill_dir: Path,
relative_path: str | None,
*,
expect_file: bool,
) -> Path:
raw_path = str(relative_path or ".").strip() or "."
normalized = Path(raw_path.replace("\\", "/"))
if normalized.is_absolute() or ".." in normalized.parts:
raise ValueError("Invalid relative path")
target = (skill_dir / normalized).resolve(strict=True)
if not target.is_relative_to(skill_dir):
raise PermissionError("Path escapes skill directory")
if expect_file and not target.is_file():
raise FileNotFoundError("Skill file not found")
if not expect_file and not target.is_dir():
raise FileNotFoundError("Skill directory not found")
return target
@staticmethod
def _skill_relative_path(skill_dir: Path, target: Path) -> str:
rel = target.relative_to(skill_dir).as_posix()
return "" if rel == "." else rel
@staticmethod
def _is_editable_skill_file(path: Path) -> bool:
return (
path.name in _EDITABLE_SKILL_FILENAMES
or path.suffix.lower() in _EDITABLE_SKILL_FILE_SUFFIXES
)
def _serialize_skill_file_entry(self, skill_dir: Path, path: Path) -> dict:
stat = path.stat()
is_dir = path.is_dir()
return {
"name": path.name,
"path": self._skill_relative_path(skill_dir, path),
"type": "directory" if is_dir else "file",
"size": 0 if is_dir else stat.st_size,
"editable": (
(not is_dir)
and self._is_editable_skill_file(path)
and stat.st_size <= _SKILL_FILE_MAX_BYTES
),
}
def _get_neo_client_config(self) -> tuple[str, str]:
provider_settings = self.core_lifecycle.astrbot_config.get(
"provider_settings",
@@ -417,6 +508,137 @@ class SkillsRoute(Route):
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def list_skill_files(self):
try:
name = str(request.args.get("name") or "").strip()
relative_path = request.args.get("path", "")
skill_dir = self._resolve_local_skill_dir(name)
target_dir = self._resolve_skill_relative_path(
skill_dir,
relative_path,
expect_file=False,
)
entries = []
for entry in sorted(
target_dir.iterdir(),
key=lambda item: (not item.is_dir(), item.name.lower()),
):
try:
resolved = entry.resolve(strict=True)
except OSError:
continue
if not resolved.is_relative_to(skill_dir):
continue
if not resolved.is_dir() and not resolved.is_file():
continue
entries.append(self._serialize_skill_file_entry(skill_dir, resolved))
return (
Response()
.ok(
{
"name": name,
"path": self._skill_relative_path(skill_dir, target_dir),
"entries": entries,
}
)
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def get_skill_file(self):
try:
name = str(request.args.get("name") or "").strip()
relative_path = request.args.get("path", "SKILL.md")
skill_dir = self._resolve_local_skill_dir(name)
target_file = self._resolve_skill_relative_path(
skill_dir,
relative_path,
expect_file=True,
)
if not self._is_editable_skill_file(target_file):
return Response().error("Unsupported file type").__dict__
size = target_file.stat().st_size
if size > _SKILL_FILE_MAX_BYTES:
return Response().error("File is too large").__dict__
try:
content = target_file.read_text(encoding="utf-8")
except UnicodeDecodeError:
return Response().error("File is not valid UTF-8 text").__dict__
return (
Response()
.ok(
{
"name": name,
"path": self._skill_relative_path(skill_dir, target_file),
"content": content,
"size": size,
"editable": True,
}
)
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def update_skill_file(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
try:
data = await request.get_json()
name = str(data.get("name") or "").strip()
relative_path = data.get("path", "SKILL.md")
content = data.get("content")
if not isinstance(content, str):
return Response().error("Missing file content").__dict__
encoded = content.encode("utf-8")
if len(encoded) > _SKILL_FILE_MAX_BYTES:
return Response().error("File content is too large").__dict__
skill_dir = self._resolve_local_skill_dir(name)
target_file = self._resolve_skill_relative_path(
skill_dir,
relative_path,
expect_file=True,
)
if not self._is_editable_skill_file(target_file):
return Response().error("Unsupported file type").__dict__
target_file.write_text(content, encoding="utf-8")
try:
await sync_skills_to_active_sandboxes()
except Exception:
logger.warning("Failed to sync edited skills to active sandboxes.")
return (
Response()
.ok(
{
"name": name,
"path": self._skill_relative_path(skill_dir, target_file),
"size": len(encoded),
}
)
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def update_skill(self):
if DEMO_MODE:
return (

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 261 icons */
/* Auto-generated MDI subset 259 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -112,6 +112,10 @@
content: "\F09D1";
}
.mdi-broadcast::before {
content: "\F1720";
}
.mdi-broom::before {
content: "\F00E2";
}
@@ -136,10 +140,6 @@
content: "\F08A7";
}
.mdi-calendar-multiple::before {
content: "\F00F1";
}
.mdi-calendar-plus::before {
content: "\F00F3";
}
@@ -360,6 +360,10 @@
content: "\F01DA";
}
.mdi-download-outline::before {
content: "\F0B8F";
}
.mdi-emoticon::before {
content: "\F0C68";
}
@@ -400,8 +404,8 @@
content: "\F0215";
}
.mdi-file-code::before {
content: "\F022E";
.mdi-file-code-outline::before {
content: "\F102B";
}
.mdi-file-delimited-outline::before {
@@ -464,10 +468,6 @@
content: "\F0234";
}
.mdi-filter-variant::before {
content: "\F0236";
}
.mdi-folder::before {
content: "\F024B";
}
@@ -552,6 +552,10 @@
content: "\F02DC";
}
.mdi-hook::before {
content: "\F06E2";
}
.mdi-identifier::before {
content: "\F0EFE";
}
@@ -760,10 +764,6 @@
content: "\F03E4";
}
.mdi-pause-circle-outline::before {
content: "\F03E6";
}
.mdi-pencil::before {
content: "\F03EB";
}
@@ -796,10 +796,6 @@
content: "\F040A";
}
.mdi-play-circle-outline::before {
content: "\F040D";
}
.mdi-plus::before {
content: "\F0415";
}
@@ -952,6 +948,10 @@
content: "\F060D";
}
.mdi-sync::before {
content: "\F04E6";
}
.mdi-text::before {
content: "\F09A8";
}
@@ -1024,14 +1024,6 @@
content: "\F056E";
}
.mdi-view-grid::before {
content: "\F0570";
}
.mdi-view-list::before {
content: "\F0572";
}
.mdi-volume-high::before {
content: "\F057E";
}

View File

@@ -1,85 +1,166 @@
<template>
<div class="tools-page">
<v-container fluid class="pa-0" elevation="0">
<!-- 页面标题 -->
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<div>
<v-btn color="success" prepend-icon="mdi-plus" class="me-2" variant="tonal"
@click="showMcpServerDialog = true" >
{{ tm('mcpServers.buttons.add') }}
</v-btn>
<v-btn color="success" prepend-icon="mdi-refresh" variant="tonal" @click="showSyncMcpServerDialog = true"
>
{{ tm('mcpServers.buttons.sync') }}
</v-btn>
</div>
</v-row>
<!-- MCP 服务器部分 -->
<div v-if="mcpServers.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
<p class="text-grey mt-4">{{ tm('mcpServers.empty') }}</p>
</div>
<v-row v-else>
<v-col v-for="(server, index) in mcpServers || []" :key="index" cols="12" md="6" lg="4" xl="3">
<item-card style="background-color: rgb(var(--v-theme-mcpCardBg));" :item="server" title-field="name"
enabled-field="active" @toggle-enabled="updateServerStatus" @delete="deleteServer" @edit="editServer">
<template v-slot:item-details="{ item }">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-file-code</v-icon>
<span class="text-caption text-medium-emphasis text-truncate" :title="getServerConfigSummary(item)">
{{ getServerConfigSummary(item) }}
</span>
</div>
<div v-else class="mcp-server-list">
<OutlinedActionListItem
v-for="server in mcpServers || []"
:key="server.name"
:title="server.name"
clickable
@click="editServer(server)"
>
<div
class="mcp-server-config text-body-2 text-medium-emphasis"
:title="getServerConfigSummary(server)"
>
<v-icon
:icon="getServerConfigIcon(server)"
size="small"
class="me-1"
/>
<span>{{ getServerConfigSummary(server) }}</span>
</div>
<div class="d-flex" style="gap: 8px;">
<div>
<div v-if="item.tools && item.tools.length > 0">
<div class="d-flex align-center mb-1">
<v-icon size="small" color="grey" class="me-2">mdi-tools</v-icon>
<v-dialog max-width="600px">
<template v-slot:activator="{ props: listToolsProps }">
<span class="text-caption text-medium-emphasis cursor-pointer" v-bind="listToolsProps"
style="text-decoration: underline;">
{{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{ item.tools.length }})
</span>
</template>
<template v-slot:default="{ isActive }">
<v-card style="padding: 16px;">
<v-card-title class="d-flex align-center">
<span>{{ tm('mcpServers.status.availableTools') }}</span>
</v-card-title>
<v-card-text>
<ul>
<li v-for="(tool, idx) in item.tools" :key="idx" style="margin: 8px 0px;">{{ tool }}</li>
</ul>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" color="primary" @click="isActive.value = false">
Close
</v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</div>
</div>
<div v-else class="text-caption text-medium-emphasis">
<v-icon size="small" color="warning" class="me-1">mdi-alert-circle</v-icon>
{{ tm('mcpServers.status.noTools') }}
</div>
</div>
<div v-if="mcpServerUpdateLoaders[item.name]" class="text-caption text-medium-emphasis">
<v-progress-circular indeterminate color="primary" size="16"></v-progress-circular>
</div>
</div>
<div class="mcp-server-tools text-caption text-medium-emphasis">
<template v-if="server.tools && server.tools.length > 0">
<v-dialog max-width="600px">
<template v-slot:activator="{ props: listToolsProps }">
<button
v-bind="listToolsProps"
class="mcp-server-tools__button"
type="button"
@click.stop
>
<v-icon size="small" class="me-1">mdi-tools</v-icon>
{{
tm('mcpServers.status.availableTools', {
count: server.tools.length,
})
}}
({{ server.tools.length }})
</button>
</template>
<template v-slot:default="{ isActive }">
<v-card style="padding: 16px;">
<v-card-title class="d-flex align-center">
<span>{{ tm('mcpServers.status.availableTools') }}</span>
</v-card-title>
<v-card-text>
<ul>
<li
v-for="(tool, idx) in server.tools"
:key="idx"
style="margin: 8px 0px;"
>
{{ tool }}
</li>
</ul>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn
variant="text"
color="primary"
@click="isActive.value = false"
>
Close
</v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</template>
</item-card>
</v-col>
</v-row>
<template v-else>
<v-icon size="small" color="warning" class="me-1">
mdi-alert-circle
</v-icon>
{{ tm('mcpServers.status.noTools') }}
</template>
</div>
<template #actions>
<v-tooltip :text="t('core.common.itemCard.delete')" location="top">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-delete-outline"
variant="text"
size="small"
class="list-action-icon-btn"
@click.stop="deleteServer(server)"
/>
</template>
</v-tooltip>
</template>
<template #control>
<v-progress-circular
v-if="mcpServerUpdateLoaders[server.name]"
indeterminate
color="primary"
size="18"
/>
<v-tooltip location="top">
<template #activator="{ props }">
<v-switch
v-bind="props"
color="primary"
density="compact"
hide-details
inset
:model-value="server.active"
:loading="mcpServerUpdateLoaders[server.name] || false"
:disabled="mcpServerUpdateLoaders[server.name] || false"
@click.stop
@update:model-value="updateServerStatus(server)"
/>
</template>
<span>{{
server.active
? t('core.common.itemCard.enabled')
: t('core.common.itemCard.disabled')
}}</span>
</v-tooltip>
</template>
</OutlinedActionListItem>
</div>
</v-container>
<div class="mcp-fab-stack">
<v-tooltip :text="tm('mcpServers.buttons.sync')" location="left">
<template #activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-sync"
size="x-large"
variant="elevated"
class="mcp-fab"
@click="showSyncMcpServerDialog = true"
/>
</template>
</v-tooltip>
<v-tooltip :text="tm('mcpServers.buttons.add')" location="left">
<template #activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-plus"
size="x-large"
variant="elevated"
class="mcp-fab"
@click="showMcpServerDialog = true"
/>
</template>
</v-tooltip>
</div>
<!-- 添加/编辑 MCP 服务器对话框 -->
<v-dialog v-model="showMcpServerDialog" max-width="750px">
<v-card>
@@ -220,8 +301,8 @@
<script>
import axios from 'axios';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
import ItemCard from '@/components/shared/ItemCard.vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import OutlinedActionListItem from '@/components/shared/OutlinedActionListItem.vue';
import {
askForConfirmation as askForConfirmationDialog,
useConfirmDialog
@@ -231,7 +312,7 @@ export default {
name: 'McpServersSection',
components: {
VueMonacoEditor,
ItemCard
OutlinedActionListItem
},
setup() {
const { t } = useI18n();
@@ -272,6 +353,9 @@ export default {
},
getServerConfigSummary() {
return (server) => {
if (server.transport) {
return String(server.transport).trim();
}
if (server.command) {
return `${server.command} ${(server.args || []).join(' ')}`;
}
@@ -283,6 +367,21 @@ export default {
}
return this.tm('mcpServers.status.noConfig');
};
},
getServerConfigIcon() {
return (server) => {
const transport = String(server.transport || '').toLowerCase();
if (transport === 'streamable_http') {
return 'mdi-web';
}
if (transport === 'sse') {
return 'mdi-broadcast';
}
if (server.command) {
return 'mdi-console-line';
}
return 'mdi-file-code-outline';
};
}
},
mounted() {
@@ -544,6 +643,73 @@ export default {
padding-top: 8px;
}
.mcp-server-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.mcp-server-config {
align-items: center;
display: flex;
overflow: hidden;
word-break: break-all;
}
.mcp-server-config span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mcp-server-tools {
align-items: center;
display: flex;
margin-top: 6px;
}
.mcp-server-tools__button {
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.62);
display: inline-flex;
text-decoration: underline;
}
.mcp-server-tools__button:hover {
color: rgb(var(--v-theme-primary));
}
.list-action-icon-btn {
color: rgba(var(--v-theme-on-surface), 0.78);
}
.list-action-icon-btn:hover {
background: rgba(var(--v-theme-on-surface), 0.08);
color: rgb(var(--v-theme-on-surface));
}
.mcp-fab-stack {
align-items: center;
bottom: 52px;
display: flex;
flex-direction: column;
gap: 16px;
position: fixed;
right: 52px;
z-index: 10000;
}
.mcp-fab {
border-radius: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.mcp-fab:hover {
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
transform: translateY(-4px) scale(1.05);
}
.monaco-container {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
@@ -551,4 +717,5 @@ export default {
margin-top: 4px;
overflow: hidden;
}
</style>

View File

@@ -1,216 +0,0 @@
<script setup>
import { computed } from 'vue';
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
const props = defineProps({
plugin: {
type: Object,
required: true
},
isPinned: {
type: Boolean,
default: false
},
tm: {
type: Function,
required: true
},
dragged: {
type: Boolean,
default: false
}
});
const emit = defineEmits([
'toggle-pin',
'view-readme',
'open-config',
'reload',
'update',
'show-info',
'uninstall',
'dragstart',
'dragover',
'dragenter',
'dragend',
'drop'
]);
const handlePinnedImgError = (e) => {
e.target.src = defaultPluginIcon;
};
const authorDisplay = computed(() => {
const p = props.plugin || {};
if (typeof p.author === 'string' && p.author.trim()) return p.author;
if (Array.isArray(p.authors) && p.authors.length) return p.authors.join(', ');
if (typeof p.author_name === 'string' && p.author_name.trim()) return p.author_name;
if (typeof p.owner === 'string' && p.owner.trim()) return p.owner;
if (p.author && typeof p.author === 'object' && p.author.name) return p.author.name;
return '';
});
</script>
<template>
<div
class="pinned-item pinned-card-wrapper"
:class="{ 'is-dragging': dragged }"
style="position:relative;"
draggable="true"
@dragstart="$emit('dragstart')"
@dragover.prevent="$emit('dragover', $event)"
@dragenter.prevent="$emit('dragenter', $event)"
@dragend="$emit('dragend', $event)"
@drop="$emit('drop', $event)"
>
<v-menu offset-y>
<template #activator="{ props: menuProps }">
<div class="d-flex flex-column align-center" style="cursor: pointer; width: 80px;">
<v-avatar
v-bind="menuProps"
size="72"
class="pinned-avatar activator-avatar mb-1"
:title="plugin.display_name || plugin.name"
>
<img
:src="(typeof plugin.logo === 'string' && plugin.logo.trim()) ? plugin.logo : defaultPluginIcon"
:alt="plugin.name"
@error="handlePinnedImgError"
/>
</v-avatar>
<span
class="text-caption text-center text-truncate"
style="width: 100%; font-size: 0.75rem; opacity: 0.9; line-height: 1.2;"
>
{{ plugin.display_name || plugin.name }}
</span>
</div>
</template>
<v-card>
<v-card-title class="d-flex" style="gap:8px; padding:12px; align-items:center;">
<div style="display:flex; align-items:center; gap:8px; min-width:0;">
<v-avatar size="40" class="pinned-avatar" style="width:40px; height:40px;">
<img
:src="(typeof plugin.logo === 'string' && plugin.logo.trim()) ? plugin.logo : defaultPluginIcon"
:alt="plugin.name"
@error="handlePinnedImgError"
/>
</v-avatar>
<div style="min-width:0; overflow:hidden;">
<div style="font-weight:600; font-size:0.95rem; white-space:nowrap; text-overflow:ellipsis; overflow:hidden;">{{ plugin.display_name || plugin.name }}</div>
<div style="font-size:0.8rem; color:var(--v-theme-on-surface); opacity:0.8; white-space:nowrap; text-overflow:ellipsis; overflow:hidden;">{{ authorDisplay || (plugin.author || '') }}</div>
</div>
</div>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="d-flex" style="gap:8px; padding:12px;">
<v-tooltip location="top" :text="tm('buttons.viewDocs')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="info" @click.stop="$emit('view-readme', plugin)">
<v-icon>mdi-book-open-page-variant</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('card.actions.pluginConfig')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="primary" @click.stop="$emit('open-config', plugin.name)">
<v-icon>mdi-cog</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('card.actions.reloadPlugin')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="primary" @click.stop="$emit('reload', plugin.name)">
<v-icon>mdi-refresh</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('buttons.update')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="warning" @click.stop="$emit('update', plugin.name)">
<v-icon>mdi-update</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('buttons.viewInfo')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="secondary" @click.stop="$emit('show-info', plugin)">
<v-icon>mdi-information</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('buttons.uninstall')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="error" @click.stop="$emit('uninstall', plugin.name)" v-if="!plugin.reserved">
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
</v-tooltip>
</v-card-text>
</v-card>
</v-menu>
<v-btn
icon
size="small"
class="pinned-pin-btn"
:color="isPinned ? 'primary' : 'secondary'"
@click.stop="$emit('toggle-pin', plugin)"
:title="isPinned ? tm('buttons.unpin') : tm('buttons.pin')"
style="position:absolute; top:6px; right:6px; min-width:22px; width:22px; height:22px;"
>
<v-icon size="14">{{ isPinned ? 'mdi-pin' : 'mdi-pin-outline' }}</v-icon>
</v-btn>
</div>
</template>
<style scoped>
.pinned-avatar {
display: inline-flex;
width: 100%;
height: 100%;
overflow: hidden;
cursor: pointer;
border-radius: 12px;
}
.pinned-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pinned-card-wrapper {
position: relative;
display: inline-block;
width: 80px;
}
.pinned-item {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.is-dragging {
opacity: 0.5;
transform: scale(0.95);
cursor: grabbing;
}
[draggable="true"] {
cursor: grab;
}
[draggable="true"]:active {
cursor: grabbing;
}
</style>

View File

@@ -1,37 +1,22 @@
<template>
<div class="skills-page">
<v-container fluid class="pa-0" elevation="0">
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-4">
<div>
<v-btn
v-if="mode === 'local'"
color="primary"
prepend-icon="mdi-upload"
class="me-2"
variant="tonal"
@click="openUploadDialog"
>
{{ tm("skills.upload") }}
</v-btn>
<v-btn
color="primary"
prepend-icon="mdi-refresh"
variant="tonal"
@click="refreshCurrentMode"
>
{{ tm("skills.refresh") }}
</v-btn>
</div>
<v-btn-toggle v-model="mode" mandatory divided density="comfortable">
<v-row
v-if="neoEnabled"
class="d-flex justify-end align-center px-4 py-3 pb-4"
>
<v-btn-toggle
v-model="mode"
mandatory
divided
density="comfortable"
>
<v-btn value="local">{{ tm("skills.modeLocal") }}</v-btn>
<v-btn value="neo" :disabled="!neoEnabled">{{
tm("skills.modeNeo")
}}</v-btn>
<v-btn value="neo">{{ tm("skills.modeNeo") }}</v-btn>
</v-btn-toggle>
</v-row>
<div v-if="mode === 'local'" class="px-2 pb-2 d-flex flex-column ga-2">
<small style="color: grey">{{ tm("skills.runtimeHint") }}</small>
<v-alert
v-if="runtime === 'sandbox' && !sandboxCache.ready"
type="info"
@@ -67,67 +52,92 @@
<small class="text-grey">{{ tm("skills.emptyHint") }}</small>
</div>
<v-row v-else align="stretch">
<v-col
<div v-else class="skills-list pb-3">
<OutlinedActionListItem
v-for="skill in skills"
:key="skill.name"
cols="12"
md="6"
lg="4"
xl="3"
class="d-flex"
:title="skill.name"
clickable
@click="openSkillEditor(skill)"
>
<item-card
:item="skill"
title-field="name"
enabled-field="active"
:loading="itemLoading[skill.name] || false"
:show-edit-button="false"
:disable-toggle="isSandboxPresetSkill(skill)"
:disable-delete="isSandboxPresetSkill(skill)"
@toggle-enabled="toggleSkill"
@delete="confirmDelete"
>
<template #item-details="{ item }">
<div class="d-flex align-center mb-2 ga-2 flex-wrap">
<v-chip
size="x-small"
variant="tonal"
:color="sourceTypeColor(item.source_type)"
>
{{ sourceTypeLabel(item.source_type) }}
</v-chip>
<div
class="text-caption text-medium-emphasis skill-description"
>
<v-icon size="small" class="me-1">mdi-text</v-icon>
{{ item.description || tm("skills.noDescription") }}
</div>
</div>
<div class="text-caption text-medium-emphasis skill-path">
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
{{ tm("skills.path") }}: {{ item.path }}
</div>
</template>
<template #actions="{ item }">
<v-btn
variant="tonal"
color="primary"
size="small"
rounded="xl"
:disabled="
itemLoading[item.name] ||
false ||
isSandboxPresetSkill(item)
"
@click="downloadSkill(item)"
>
{{ tm("skills.download") }}
</v-btn>
</template>
</item-card>
</v-col>
</v-row>
<template #title-extra>
<v-chip
size="x-small"
variant="tonal"
:color="sourceTypeColor(skill.source_type)"
>
{{ sourceTypeLabel(skill.source_type) }}
</v-chip>
</template>
<div class="skill-description text-body-2 text-medium-emphasis">
{{ skill.description || tm("skills.noDescription") }}
</div>
<div class="skill-path text-caption text-medium-emphasis">
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
{{ tm("skills.path") }}: {{ skill.path }}
</div>
<template #actions>
<v-tooltip :text="tm('skills.download')" location="top">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-download-outline"
variant="text"
size="small"
class="list-action-icon-btn"
:disabled="
itemLoading[skill.name] ||
false ||
isSandboxPresetSkill(skill)
"
@click.stop="downloadSkill(skill)"
/>
</template>
</v-tooltip>
<v-tooltip :text="t('core.common.itemCard.delete')" location="top">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-delete-outline"
variant="text"
size="small"
class="list-action-icon-btn"
:disabled="itemLoading[skill.name] || isSandboxPresetSkill(skill)"
@click.stop="confirmDelete(skill)"
/>
</template>
</v-tooltip>
</template>
<template #control>
<v-tooltip location="top">
<template #activator="{ props }">
<v-switch
v-bind="props"
color="primary"
density="compact"
hide-details
inset
:model-value="skill.active"
:loading="itemLoading[skill.name] || false"
:disabled="itemLoading[skill.name] || isSandboxPresetSkill(skill)"
@click.stop
@update:model-value="toggleSkill(skill)"
/>
</template>
<span>{{
skill.active
? t("core.common.itemCard.enabled")
: t("core.common.itemCard.disabled")
}}</span>
</v-tooltip>
</template>
</OutlinedActionListItem>
</div>
</template>
<template v-else-if="mode === 'neo' && neoEnabled">
@@ -141,14 +151,6 @@
{{ tm("skills.neoFilterHint") }}
</div>
</div>
<v-btn
color="primary"
prepend-icon="mdi-refresh"
variant="flat"
@click="fetchNeoData"
>
{{ tm("skills.refresh") }}
</v-btn>
</div>
<v-row class="ga-md-0 ga-2">
@@ -339,6 +341,39 @@
</template>
</v-container>
<div class="skills-fab-stack">
<v-tooltip :text="tm('skills.refresh')" location="left">
<template #activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-refresh"
size="x-large"
variant="elevated"
class="skills-fab"
@click="refreshCurrentMode"
/>
</template>
</v-tooltip>
<v-tooltip
v-if="mode === 'local'"
:text="tm('skills.upload')"
location="left"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-upload"
size="x-large"
variant="elevated"
class="skills-fab"
@click="openUploadDialog"
/>
</template>
</v-tooltip>
</div>
<v-dialog v-model="uploadDialog" max-width="880px" :persistent="uploading">
<v-card class="skills-upload-dialog">
<v-card-title class="skills-upload-dialog__header px-6 pt-6 pb-2">
@@ -561,6 +596,142 @@
</v-card>
</v-dialog>
<v-dialog
v-model="editorDialog.show"
max-width="1180px"
:persistent="editorDialog.saving"
>
<v-card class="skill-editor-dialog">
<v-card-title class="skill-editor-dialog__header">
<div>
<div class="text-h3 font-weight-bold">
{{ editorDialog.skillName }}
</div>
</div>
<v-btn
icon="mdi-close"
variant="text"
:disabled="editorDialog.saving"
@click="closeSkillEditor"
/>
</v-card-title>
<v-card-text class="skill-editor-dialog__body">
<div class="skill-editor">
<div class="skill-editor__files">
<div class="skill-editor__files-header">
<v-btn
icon="mdi-arrow-up"
size="small"
variant="text"
:disabled="!editorDialog.currentDir || editorDialog.loadingFiles"
@click="openParentSkillDir"
/>
<span>{{ editorDialog.currentDir || "/" }}</span>
</div>
<v-progress-linear
v-if="editorDialog.loadingFiles"
indeterminate
color="primary"
/>
<div v-else class="skill-editor__file-list">
<button
v-for="entry in editorDialog.entries"
:key="`${entry.type}:${entry.path}`"
class="skill-editor__file-row"
:class="{
'skill-editor__file-row--active':
editorDialog.filePath === entry.path,
}"
type="button"
@click="openSkillEntry(entry)"
>
<v-icon size="18">
{{
entry.type === "directory"
? "mdi-folder-outline"
: "mdi-file-document-outline"
}}
</v-icon>
<span>{{ entry.name }}</span>
<v-chip
v-if="entry.type === 'file' && !entry.editable"
size="x-small"
variant="tonal"
>
{{ tm("skills.readonly") }}
</v-chip>
</button>
</div>
</div>
<div class="skill-editor__content">
<div class="skill-editor__content-header">
<div class="skill-editor__path">
{{ editorDialog.filePath || tm("skills.noFileSelected") }}
</div>
<v-chip
v-if="editorDialog.fileDirty"
size="small"
color="warning"
variant="tonal"
>
{{ tm("skills.unsaved") }}
</v-chip>
</div>
<v-alert
v-if="editorDialog.error"
type="error"
variant="tonal"
density="compact"
class="mb-3"
>
{{ editorDialog.error }}
</v-alert>
<div class="skill-editor__monaco">
<VueMonacoEditor
v-model:value="editorDialog.content"
:theme="editorTheme"
:language="editorLanguage"
:options="editorOptions"
style="height: 100%; width: 100%;"
@change="editorDialog.fileDirty = true"
/>
</div>
</div>
</div>
</v-card-text>
<v-card-actions class="skill-editor-dialog__actions">
<v-spacer />
<v-btn
variant="text"
:disabled="editorDialog.saving"
@click="closeSkillEditor"
>
{{ tm("skills.cancel") }}
</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="editorDialog.saving"
:disabled="
!editorDialog.filePath ||
!editorDialog.fileEditable ||
!editorDialog.fileDirty
"
@click="saveSkillFile"
>
{{ tm("skills.saveFile") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="payloadDialog.show" max-width="820px">
<v-card>
<v-card-title>{{ tm("skills.neoPayloadTitle") }}</v-card-title>
@@ -588,9 +759,11 @@
<script>
import axios from "axios";
import { computed, onMounted, reactive, ref, watch } from "vue";
import ItemCard from "@/components/shared/ItemCard.vue";
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
import { VueMonacoEditor } from "@guolao/vue-monaco-editor";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import OutlinedActionListItem from "@/components/shared/OutlinedActionListItem.vue";
import { useCustomizerStore } from "@/stores/customizer";
const STATUS_WAITING = "waiting";
const STATUS_UPLOADING = "uploading";
@@ -600,10 +773,11 @@ const STATUS_SKIPPED = "skipped";
export default {
name: "SkillsSection",
components: { ItemCard },
components: { OutlinedActionListItem, VueMonacoEditor },
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n("features/extension");
const customizer = useCustomizerStore();
const mode = ref("local");
const skills = ref([]);
@@ -634,6 +808,20 @@ export default {
show: false,
content: "",
});
const editorDialog = reactive({
show: false,
skillName: "",
currentDir: "",
entries: [],
filePath: "",
content: "",
fileEditable: false,
fileDirty: false,
loadingFiles: false,
loadingFile: false,
saving: false,
error: "",
});
const neoEnabled = ref(false);
const neoUnavailableMessage = ref("");
@@ -659,6 +847,33 @@ export default {
const activeReleaseCount = computed(
() => neoReleases.value.filter((item) => item?.is_active).length,
);
const editorLanguage = computed(() => {
const path = String(editorDialog.filePath || "").toLowerCase();
if (path.endsWith(".json")) return "json";
if (path.endsWith(".yaml") || path.endsWith(".yml")) return "yaml";
if (path.endsWith(".toml") || path.endsWith(".ini")) return "ini";
if (path.endsWith(".py")) return "python";
if (path.endsWith(".js")) return "javascript";
if (path.endsWith(".ts")) return "typescript";
if (path.endsWith(".html")) return "html";
if (path.endsWith(".css")) return "css";
if (path.endsWith(".sh")) return "shell";
if (path.endsWith(".md") || path.endsWith(".txt")) return "markdown";
return "plaintext";
});
const editorTheme = computed(() =>
customizer.uiTheme === "PurpleThemeDark" ? "vs-dark" : "vs-light",
);
const editorOptions = computed(() => ({
automaticLayout: true,
fontSize: 13,
lineNumbers: "on",
minimap: { enabled: false },
readOnly: !editorDialog.fileEditable || editorDialog.loadingFile,
scrollBeyondLastLine: false,
tabSize: 2,
wordWrap: "on",
}));
const uploadStateCounts = computed(() =>
uploadItems.value.reduce(
(counts, item) => {
@@ -1105,6 +1320,167 @@ export default {
}
};
const resetEditorDialog = () => {
editorDialog.skillName = "";
editorDialog.currentDir = "";
editorDialog.entries = [];
editorDialog.filePath = "";
editorDialog.content = "";
editorDialog.fileEditable = false;
editorDialog.fileDirty = false;
editorDialog.loadingFiles = false;
editorDialog.loadingFile = false;
editorDialog.saving = false;
editorDialog.error = "";
};
const loadSkillDir = async (path = "") => {
if (!editorDialog.skillName) return [];
editorDialog.loadingFiles = true;
editorDialog.error = "";
try {
const res = await axios.get("/api/skills/files", {
params: { name: editorDialog.skillName, path },
});
if (res?.data?.status !== "ok") {
editorDialog.error =
res?.data?.message || tm("skills.editorLoadFailed");
return [];
}
const payload = res.data.data || {};
editorDialog.currentDir = payload.path || "";
editorDialog.entries = Array.isArray(payload.entries)
? payload.entries
: [];
return editorDialog.entries;
} catch (_err) {
editorDialog.error = tm("skills.editorLoadFailed");
return [];
} finally {
editorDialog.loadingFiles = false;
}
};
const loadSkillFile = async (path) => {
if (!editorDialog.skillName || !path) return;
if (
editorDialog.fileDirty &&
!window.confirm(tm("skills.discardChanges"))
) {
return;
}
editorDialog.loadingFile = true;
editorDialog.error = "";
try {
const res = await axios.get("/api/skills/file", {
params: { name: editorDialog.skillName, path },
});
if (res?.data?.status !== "ok") {
editorDialog.error =
res?.data?.message || tm("skills.editorLoadFailed");
return;
}
const payload = res.data.data || {};
editorDialog.filePath = payload.path || path;
editorDialog.content = payload.content || "";
editorDialog.fileEditable = payload.editable !== false;
await nextTick();
editorDialog.fileDirty = false;
} catch (_err) {
editorDialog.error = tm("skills.editorLoadFailed");
} finally {
editorDialog.loadingFile = false;
}
};
const openSkillEditor = async (skill) => {
if (isSandboxPresetSkill(skill)) {
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
return;
}
resetEditorDialog();
editorDialog.skillName = skill.name;
editorDialog.show = true;
const entries = await loadSkillDir("");
const skillMd = entries.find((entry) => entry.path === "SKILL.md");
if (skillMd?.editable) {
await loadSkillFile(skillMd.path);
}
};
const closeSkillEditor = () => {
if (editorDialog.saving) return;
if (
editorDialog.fileDirty &&
!window.confirm(tm("skills.discardChanges"))
) {
return;
}
editorDialog.show = false;
resetEditorDialog();
};
const openSkillEntry = async (entry) => {
if (!entry) return;
if (entry.type === "directory") {
if (
editorDialog.fileDirty &&
!window.confirm(tm("skills.discardChanges"))
) {
return;
}
await loadSkillDir(entry.path);
return;
}
await loadSkillFile(entry.path);
};
const openParentSkillDir = async () => {
if (!editorDialog.currentDir) return;
if (
editorDialog.fileDirty &&
!window.confirm(tm("skills.discardChanges"))
) {
return;
}
const parts = editorDialog.currentDir.split("/").filter(Boolean);
parts.pop();
await loadSkillDir(parts.join("/"));
};
const saveSkillFile = async () => {
if (
!editorDialog.skillName ||
!editorDialog.filePath ||
!editorDialog.fileEditable
) {
return;
}
editorDialog.saving = true;
editorDialog.error = "";
try {
const res = await axios.post("/api/skills/file", {
name: editorDialog.skillName,
path: editorDialog.filePath,
content: editorDialog.content,
});
if (res?.data?.status !== "ok") {
editorDialog.error =
res?.data?.message || tm("skills.editorSaveFailed");
showMessage(editorDialog.error, "error");
return;
}
editorDialog.fileDirty = false;
showMessage(tm("skills.editorSaveSuccess"), "success");
await fetchSkills();
} catch (_err) {
editorDialog.error = tm("skills.editorSaveFailed");
showMessage(tm("skills.editorSaveFailed"), "error");
} finally {
editorDialog.saving = false;
}
};
const fetchNeoCandidates = async () => {
const params = {
skill_key: neoFilters.skill_key || undefined,
@@ -1412,6 +1788,10 @@ export default {
candidateHeaders,
releaseHeaders,
payloadDialog,
editorDialog,
editorLanguage,
editorTheme,
editorOptions,
formatFileSize,
uploadStatusLabel,
statusChipClass,
@@ -1425,6 +1805,11 @@ export default {
fetchNeoData,
uploadSkillBatch,
downloadSkill,
openSkillEditor,
closeSkillEditor,
openSkillEntry,
openParentSkillDir,
saveSkillFile,
toggleSkill,
confirmDelete,
deleteSkill,
@@ -1448,23 +1833,178 @@ export default {
</script>
<style scoped>
.skills-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.list-action-icon-btn {
color: rgba(var(--v-theme-on-surface), 0.78);
}
.list-action-icon-btn:hover {
background: rgba(var(--v-theme-on-surface), 0.08);
color: rgb(var(--v-theme-on-surface));
}
.skills-fab-stack {
align-items: center;
bottom: 52px;
display: flex;
flex-direction: column;
gap: 16px;
position: fixed;
right: 52px;
z-index: 10000;
}
.skills-fab {
border-radius: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.skills-fab:hover {
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
transform: translateY(-4px) scale(1.05);
}
.skill-description {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 20px;
}
.skill-path {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
margin-top: 6px;
overflow: hidden;
min-height: 40px;
word-break: break-all;
}
.skill-editor-dialog {
max-height: min(88vh, 980px);
overflow: hidden;
}
.skill-editor-dialog__header {
align-items: flex-start;
display: flex;
justify-content: space-between;
gap: 16px;
padding: 18px 22px 14px;
}
.skill-editor-dialog__body {
min-height: 0;
padding: 16px 22px;
}
.skill-editor-dialog__actions {
border-top: 1px solid var(--v-theme-border);
padding: 12px 22px;
}
.skill-editor {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 16px;
min-height: 560px;
}
.skill-editor__files {
border: 1px solid rgba(128, 128, 128, 0.28);
border-radius: 10px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.skill-editor__files-header {
align-items: center;
border-bottom: 1px solid rgba(128, 128, 128, 0.28);
display: flex;
gap: 8px;
min-height: 44px;
padding: 6px 10px;
}
.skill-editor__files-header span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-editor__file-list {
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
padding: 6px;
}
.skill-editor__file-row {
align-items: center;
border-radius: 8px;
color: rgb(var(--v-theme-on-surface));
display: grid;
gap: 8px;
grid-template-columns: auto minmax(0, 1fr) auto;
min-height: 34px;
padding: 6px 8px;
text-align: left;
}
.skill-editor__file-row:hover,
.skill-editor__file-row--active {
background: rgba(var(--v-theme-on-surface), 0.06);
}
.skill-editor__file-row--active {
background: rgba(var(--v-theme-on-surface), 0.1);
}
.skill-editor__file-row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-editor__content {
display: flex;
flex-direction: column;
min-width: 0;
}
.skill-editor__content-header {
align-items: center;
display: flex;
gap: 10px;
justify-content: space-between;
min-height: 34px;
}
.skill-editor__path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-editor__monaco {
border: 1px solid rgba(128, 128, 128, 0.28);
border-radius: 10px;
flex: 1 1 auto;
margin-top: 12px;
min-height: 520px;
overflow: hidden;
position: relative;
}
.skills-upload-dialog {
display: flex;
flex-direction: column;

View File

@@ -89,6 +89,7 @@
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
:saving-providers="savingProviderToggles"
:testing-providers="testingProviders"
:tm="tm"
@fetch-models="fetchAvailableModels"
@@ -209,6 +210,7 @@ const {
availableModels,
loadingModels,
savingSource,
savingProviderToggles,
testingProviders,
isSourceModified,
configSchema,

View File

@@ -84,12 +84,13 @@
<div class="provider-model-row__actions" @click.stop>
<v-switch
v-model="entry.provider.enable"
:model-value="entry.provider.enable"
density="compact"
inset
hide-details
color="primary"
class="provider-model-row__switch"
:disabled="isProviderSaving(entry.provider.id)"
@update:modelValue="emit('toggle-provider-enable', entry.provider, $event)"
></v-switch>
@@ -97,7 +98,7 @@
icon="mdi-connection"
size="small"
variant="text"
:disabled="!entry.provider.enable"
:disabled="!entry.provider.enable || isProviderSaving(entry.provider.id)"
:loading="isProviderTesting(entry.provider.id)"
@click.stop="emit('test-provider', entry.provider)"
></v-btn>
@@ -240,6 +241,10 @@ const props = defineProps({
type: Array,
default: () => []
},
savingProviders: {
type: Array,
default: () => []
},
tm: {
type: Function,
required: true
@@ -288,6 +293,7 @@ const capabilityIcons = (metadata) => {
}
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
const isProviderSaving = (providerId) => props.savingProviders.includes(providerId)
</script>
<style scoped>

View File

@@ -87,11 +87,12 @@
></v-checkbox>
</div>
<v-select
<v-autocomplete
v-else-if="itemMeta?.type === 'list' && itemMeta?.options"
:model-value="modelValue"
@update:model-value="emitUpdate"
:items="getSelectItems(itemMeta)"
@update:model-value="val => { emitUpdate(val); listSearchText = '' }"
v-model:search="listSearchText"
:items="listSelectItems"
item-title="title"
item-value="value"
:disabled="itemMeta?.readonly"
@@ -101,7 +102,7 @@
hide-details
chips
multiple
></v-select>
></v-autocomplete>
<v-select
v-else-if="itemMeta?.options"
@@ -238,10 +239,11 @@ import PersonaSelector from './PersonaSelector.vue'
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
import PluginSetSelector from './PluginSetSelector.vue'
import T2ITemplateEditor from './T2ITemplateEditor.vue'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
const numericTemp = ref(null)
const listSearchText = ref('')
const props = defineProps({
modelValue: {
@@ -278,6 +280,12 @@ function emitUpdate(val) {
emit('update:modelValue', val)
}
const listSelectItems = computed(() =>
props.itemMeta?.type === 'list' && props.itemMeta?.options
? getSelectItems(props.itemMeta)
: []
)
function toNumber(val) {
const n = parseFloat(val)
return isNaN(n) ? 0 : n

View File

@@ -23,7 +23,7 @@ import { EventSourcePolyfill } from 'event-source-polyfill';
></v-btn>
</div>
<div id="term" style="background-color: #1e1e1e; padding: 16px; border-radius: 8px; overflow-y:auto; height: 100%">
<div id="term" class="console-term">
</div>
</div>
</template>
@@ -279,6 +279,36 @@ export default {
this.isFullscreen = !!document.fullscreenElement;
},
appendLogContent(element, log) {
const levelMatch = log.match(/\[(DEBG|INFO|WARN|ERRO|CRIT|DEBUG|WARNING|ERROR|CRITICAL)\]/);
if (!levelMatch) {
element.innerText = `${log}`;
return;
}
const levelStart = levelMatch.index;
const levelEnd = levelStart + levelMatch[0].length;
const prefix = log.slice(0, levelStart).trimEnd();
const message = log.slice(levelEnd).trimStart();
const prefixSpan = document.createElement('span');
prefixSpan.className = 'console-log-prefix';
prefixSpan.innerText = prefix;
const levelSpan = document.createElement('span');
levelSpan.className = 'console-log-level';
levelSpan.innerText = levelMatch[0];
const messageSpan = document.createElement('span');
messageSpan.className = 'console-log-message';
messageSpan.innerText = message;
element.classList.add('console-log-line--structured');
element.appendChild(prefixSpan);
element.appendChild(levelSpan);
element.appendChild(messageSpan);
},
printLog(log) {
let ele = document.getElementById('term')
if (!ele) {
@@ -297,7 +327,7 @@ export default {
span.style = style
span.classList.add('console-log-line', 'fade-in')
span.innerText = `${log}`;
this.appendLogContent(span, log);
ele.appendChild(span)
if (this.autoScroll) {
ele.scrollTop = ele.scrollHeight
@@ -325,7 +355,14 @@ export default {
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
margin-left: 20px;
}
.console-term {
background-color: #1e1e1e;
border-radius: 8px;
height: 100%;
overflow-y: auto;
padding: 16px;
}
.fullscreen-btn {
@@ -334,12 +371,35 @@ export default {
:deep(.console-log-line) {
display: block;
margin-bottom: 2px;
margin: 0 0 2px;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, var(--astrbot-font-cjk-mono), monospace;
font-size: 12px;
white-space: pre-wrap;
}
:deep(.console-log-line--structured) {
display: grid;
grid-template-columns: max-content 10ch minmax(0, 1fr);
column-gap: 8px;
align-items: start;
white-space: normal;
}
:deep(.console-log-prefix),
:deep(.console-log-level),
:deep(.console-log-message) {
min-width: 0;
white-space: pre-wrap;
}
:deep(.console-log-level) {
font-variant-numeric: tabular-nums;
}
:deep(.console-log-message) {
overflow-wrap: anywhere;
}
:deep(.fade-in) {
animation: fadeIn 0.3s;
}

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import { ref, computed, inject, watch, useAttrs } from "vue";
import { ref, computed, watch, useAttrs } 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";
@@ -13,10 +12,6 @@ const props = defineProps({
type: Object,
required: true,
},
pinned: {
type: Boolean,
default: false,
},
marketMode: {
type: Boolean,
default: false,
@@ -25,6 +20,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
isPinned: {
type: Boolean,
default: false,
},
});
// 定义要发送到父组件的事件
@@ -32,16 +31,14 @@ const emit = defineEmits([
"configure",
"update",
"reload",
"install",
"uninstall",
"toggle-activation",
"toggle-pin",
"view-handlers",
"view-readme",
"view-changelog",
"toggle-pin",
]);
const reveal = ref(false);
const showUninstallDialog = ref(false);
const attrs = useAttrs();
@@ -57,10 +54,6 @@ const supportPlatforms = computed(() => {
return platforms.filter((item) => typeof item === "string");
});
const supportPlatformDisplayNames = computed(() =>
supportPlatforms.value.map((platformId) => getPlatformDisplayName(platformId)),
);
const astrbotVersionRequirement = computed(() => {
const versionSpec = props.extension?.astrbot_version;
return typeof versionSpec === "string" && versionSpec.trim().length
@@ -68,17 +61,6 @@ const astrbotVersionRequirement = computed(() => {
: "";
});
// 作者显示(兼容多种字段名)
const authorDisplay = computed(() => {
const ext = props.extension || {};
if (typeof ext.author === 'string' && ext.author.trim()) return ext.author;
if (Array.isArray(ext.authors) && ext.authors.length) return ext.authors.join(', ');
if (typeof ext.author_name === 'string' && ext.author_name.trim()) return ext.author_name;
if (typeof ext.owner === 'string' && ext.owner.trim()) return ext.owner;
if (ext.author && typeof ext.author === 'object' && ext.author.name) return ext.author.name;
return '';
});
const logoLoadFailed = ref(false);
const logoSrc = computed(() => {
@@ -111,12 +93,6 @@ const reloadExtension = () => {
emit("reload", props.extension);
};
const $confirm = inject("$confirm");
const installExtension = async () => {
emit("install", props.extension);
};
const uninstallExtension = async () => {
showUninstallDialog.value = true;
};
@@ -132,11 +108,6 @@ const toggleActivation = () => {
emit("toggle-activation", props.extension);
};
const togglePin = (e?: Event) => {
if (e) e.stopPropagation();
emit("toggle-pin", props.extension);
};
const viewHandlers = () => {
emit("view-handlers", props.extension);
};
@@ -149,14 +120,20 @@ const viewChangelog = () => {
emit("view-changelog", props.extension);
};
const togglePin = () => {
emit("toggle-pin", props.extension);
};
</script>
<template>
<v-card
v-bind="attrs"
class="mx-auto d-flex flex-column h-100"
class="extension-card mx-auto d-flex flex-column h-100"
elevation="0"
height="100%"
:ripple="false"
variant="outlined"
:style="{
position: 'relative',
backgroundColor:
@@ -173,15 +150,18 @@ const viewChangelog = () => {
: '#ffffffdd',
}"
>
<v-card-text
style="
padding: 16px;
padding-bottom: 0px;
width: 100%;
"
>
<div style="overflow-x: auto; width: 100%">
<div style="width: 100%; margin-bottom: 24px">
<v-card-text class="extension-card-text">
<div class="extension-content-row">
<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-title-row">
<p
class="text-h3 font-weight-black extension-title"
@@ -204,6 +184,17 @@ const viewChangelog = () => {
}}</span>
</template>
</v-tooltip>
<span v-if="extension.version" class="extension-version">
{{ extension.version }}
</span>
<v-chip
v-if="extension.reserved"
color="primary"
size="x-small"
class="extension-system-chip"
>
{{ tm("status.system") }}
</v-chip>
<v-tooltip
location="top"
v-if="extension?.has_update && !marketMode"
@@ -229,157 +220,67 @@ const viewChangelog = () => {
<template v-if="!marketMode">
<v-tooltip location="left">
<template v-slot:activator="{ props: tooltipProps }">
<div class="extension-switch-wrap" @click.stop>
<div v-bind="tooltipProps" style="display:inline-flex; align-items:center;">
<v-switch
:model-value="extension.activated"
color="success"
density="compact"
hide-details
inset
@update:model-value="toggleActivation"
></v-switch>
</div>
<v-tooltip location="top" :text="pinned ? tm('buttons.unpin') : tm('buttons.pin')">
<template #activator="{ props: pinProps }">
<v-btn
v-bind="pinProps"
icon
size="small"
variant="tonal"
:color="pinned ? 'primary' : 'secondary'"
class="ml-2"
@click.stop="togglePin"
>
<v-icon size="18">{{ pinned ? 'mdi-pin' : 'mdi-pin-outline' }}</v-icon>
</v-btn>
</template>
</v-tooltip>
</div>
<div class="extension-switch-wrap" @click.stop>
<div
v-bind="tooltipProps"
style="display: inline-flex; align-items: center"
>
<v-switch
:model-value="extension.activated"
color="success"
density="compact"
hide-details
inset
@update:model-value="toggleActivation"
></v-switch>
</div>
</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="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-chip-group d-flex flex-wrap">
<v-chip
v-if="extension?.has_update"
color="warning"
label
size="small"
style="cursor: pointer"
@click.stop="updateExtension"
>
<v-icon icon="mdi-arrow-up-bold" start></v-icon>
{{ extension.online_version }}
</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-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"
style="cursor: pointer"
@click="updateExtension"
>
<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="authorDisplay" color="info" label size="small">
<v-icon icon="mdi-account" start></v-icon>
{{ authorDisplay }}
</v-chip>
<v-chip
v-if="astrbotVersionRequirement"
color="secondary"
variant="outlined"
label
size="small"
>
AstrBot: {{ astrbotVersionRequirement }}
</v-chip>
</div>
<div
class="extension-desc"
:class="{ 'text-caption': $vuetify.display.xs }"
>
{{ extension.desc }}
</div>
</div>
<div
class="extension-desc"
:class="{ 'text-caption': $vuetify.display.xs }"
>
{{ extension.desc }}
</div>
</div>
</div>
@@ -388,6 +289,22 @@ const viewChangelog = () => {
<v-card-actions class="extension-actions" @click.stop>
<template v-if="!marketMode">
<v-spacer></v-spacer>
<v-tooltip location="top">
<template v-slot:activator="{ props: pinTooltipProps }">
<v-btn
v-bind="pinTooltipProps"
:aria-label="isPinned ? tm('buttons.unpin') : tm('buttons.pin')"
:color="isPinned ? 'primary' : 'secondary'"
:icon="isPinned ? 'mdi-pin' : 'mdi-pin-outline'"
size="small"
variant="tonal"
class="extension-pin-btn"
@click="togglePin"
></v-btn>
</template>
<span>{{ isPinned ? tm("buttons.unpin") : tm("buttons.pin") }}</span>
</v-tooltip>
<v-tooltip location="top" :text="tm('buttons.viewDocs')">
<template v-slot:activator="{ props: actionProps }">
<v-btn
@@ -414,20 +331,6 @@ const viewChangelog = () => {
</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
@@ -485,6 +388,15 @@ const viewChangelog = () => {
</template>
<style scoped>
.extension-card-text {
padding: 12px 14px 8px;
width: 100%;
}
.extension-card {
cursor: pointer;
}
.extension-image-container {
display: flex;
align-items: flex-start;
@@ -492,15 +404,15 @@ const viewChangelog = () => {
}
.extension-logo {
width: 72px;
height: 72px;
border-radius: 12px;
width: 64px;
height: 64px;
border-radius: 10px;
object-fit: cover;
}
.extension-content-row {
display: flex;
gap: 12px;
gap: 14px;
align-items: flex-start;
}
@@ -510,14 +422,17 @@ const viewChangelog = () => {
}
.extension-chip-group {
gap: 8px;
gap: 6px;
}
.extension-desc {
margin-top: 8px;
margin-top: 6px;
font-size: 90%;
overflow-y: auto;
height: 70px;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.extension-title {
@@ -542,36 +457,50 @@ const viewChangelog = () => {
white-space: nowrap;
}
.extension-version {
color: rgba(var(--v-theme-on-surface), 0.48);
flex-shrink: 0;
font-size: 0.875rem;
font-weight: 500;
margin-left: 10px;
white-space: nowrap;
}
.extension-system-chip {
flex-shrink: 0;
margin-left: 8px;
}
.extension-switch-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
}
.extension-pin-btn {
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-content-row {
flex-direction: column;
gap: 10px;
}
.extension-logo {
width: 64px;
height: 64px;
width: 52px;
height: 52px;
}
}
.extension-actions {
margin-top: auto;
gap: 8px;
gap: 6px;
justify-content: flex-end;
min-height: 42px;
padding: 0 12px 10px;
}
</style>

View File

@@ -0,0 +1,149 @@
<template>
<v-card
class="outlined-action-list-item rounded-lg"
:class="{ 'outlined-action-list-item--clickable': clickable }"
variant="outlined"
:ripple="false"
@click="handleClick"
>
<div class="outlined-action-list-item__main">
<div class="outlined-action-list-item__content">
<div class="outlined-action-list-item__header">
<slot name="title-prepend"></slot>
<div class="outlined-action-list-item__title">
{{ title }}
</div>
<slot name="title-extra"></slot>
</div>
<slot></slot>
</div>
<div
v-if="$slots.actions || $slots.control"
class="outlined-action-list-item__actions"
>
<div
v-if="$slots.actions"
class="outlined-action-list-item__hover-actions"
>
<slot name="actions"></slot>
</div>
<slot name="control"></slot>
</div>
</div>
</v-card>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
clickable: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["click"]);
const handleClick = (event) => {
if (!props.clickable) return;
emit("click", event);
};
</script>
<style scoped>
.outlined-action-list-item {
background: rgb(var(--v-theme-surface));
transition: background-color 0.16s ease;
}
.outlined-action-list-item:hover,
.outlined-action-list-item:focus-within {
background: rgba(var(--v-theme-on-surface), 0.04);
}
.outlined-action-list-item--clickable {
cursor: pointer;
}
.outlined-action-list-item :deep(.v-card__overlay),
.outlined-action-list-item :deep(.v-ripple__container) {
display: none;
}
.outlined-action-list-item__main {
align-items: center;
display: flex;
gap: 16px;
justify-content: space-between;
min-height: 104px;
padding: 16px;
}
.outlined-action-list-item__content {
min-width: 0;
}
.outlined-action-list-item__header {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 6px;
}
.outlined-action-list-item__title {
font-size: 1rem;
font-weight: 700;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outlined-action-list-item__actions {
align-items: center;
display: flex;
flex-shrink: 0;
gap: 8px;
margin-left: auto;
}
.outlined-action-list-item__hover-actions {
align-items: center;
display: flex;
gap: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.16s ease;
}
.outlined-action-list-item:hover .outlined-action-list-item__hover-actions,
.outlined-action-list-item:focus-within .outlined-action-list-item__hover-actions {
opacity: 1;
pointer-events: auto;
}
@media (max-width: 860px) {
.outlined-action-list-item__main {
align-items: stretch;
flex-direction: column;
}
.outlined-action-list-item__actions {
flex-wrap: wrap;
justify-content: flex-end;
margin-left: 0;
}
.outlined-action-list-item__hover-actions {
opacity: 1;
pointer-events: auto;
}
}
</style>

View File

@@ -58,6 +58,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
const modelMetadata = ref<Record<string, any>>({})
const loadingModels = ref(false)
const savingSource = ref(false)
const savingProviderToggles = ref<string[]>([])
const testingProviders = ref<string[]>([])
const isSourceModified = ref(false)
const configSchema = ref<Record<string, any>>({})
@@ -610,6 +611,33 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
}
}
async function toggleProviderEnable(provider: any, value: boolean) {
if (!provider?.id || savingProviderToggles.value.includes(provider.id)) {
return false
}
savingProviderToggles.value.push(provider.id)
try {
const nextConfig = { ...provider, enable: Boolean(value) }
const response = await axios.post('/api/config/provider/update', {
id: provider.id,
config: nextConfig
})
if (response.data.status === 'error') {
throw new Error(response.data.message)
}
provider.enable = nextConfig.enable
showMessage(response.data.message || tm('messages.success.statusUpdate'))
return true
} catch (error: any) {
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
return false
} finally {
await loadConfig()
savingProviderToggles.value = savingProviderToggles.value.filter((id) => id !== provider.id)
}
}
async function testProvider(provider: any) {
testingProviders.value.push(provider.id)
try {
@@ -629,7 +657,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
}
async function loadConfig() {
loadProviderTemplate()
await loadProviderTemplate()
}
async function loadProviderTemplate() {
@@ -670,6 +698,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
modelMetadata,
loadingModels,
savingSource,
savingProviderToggles,
testingProviders,
isSourceModified,
configSchema,
@@ -711,6 +740,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
addModelProvider,
deleteProvider,
modelAlreadyConfigured,
toggleProviderEnable,
testProvider,
loadConfig,
loadProviderTemplate

View File

@@ -52,6 +52,8 @@
"selectFile": "Select File",
"refresh": "Refresh",
"updateAll": "Update All",
"pin": "Pin",
"unpin": "Unpin",
"deleteSource": "Delete Source",
"reshuffle": "Shuffle Again"
},
@@ -91,7 +93,27 @@
},
"empty": {
"noPlugins": "No Extensions",
"noPluginsDesc": "Try installing extensions or showing system extensions"
"noPluginsDesc": "Try installing extensions or adjusting the search"
},
"detail": {
"contents": "Plugin Handlers",
"noContents": "No handlers",
"notFound": "Plugin not found",
"docsTitle": "Documentation",
"docsEmpty": "No documentation",
"handlerGroups": {
"commands": "Commands / Command Groups",
"hooks": "Hooks",
"functionTools": "Function Tools",
"eventListeners": "Event Listeners"
},
"info": {
"title": "Info",
"author": "Author",
"category": "Category",
"authorWebsite": "Author Website",
"repository": "Repository"
}
},
"market": {
"recommended": "🥳 Recommended",
@@ -144,8 +166,7 @@
"updated": "Last Updated",
"updateStatus": "Update Status",
"ascending": "Ascending",
"descending": "Descending",
"pinUpdatesOnTop": "Pin Updates on Top"
"descending": "Descending"
},
"tags": {
"danger": "Danger"
@@ -223,6 +244,7 @@
"supportedFormats": "Supports .zip extension files",
"updateAllSuccess": "All upgradable extensions have been updated!",
"updateAllFailed": "{failed} of {total} extensions failed to update:",
"noUpdatesAvailable": "No extensions have updates available",
"fillSourceNameAndUrl": "Please fill in the complete source name and URL",
"invalidUrl": "Please enter a valid URL",
"enterJsonUrl": "Please enter a URL that returns plugin list JSON data"
@@ -328,7 +350,17 @@
"sourceSandboxOnly": "Sandbox Preset Skill",
"sourceBoth": "Local + Sandbox",
"sandboxDiscoveryPending": "Sandbox preset skills have not been discovered yet. Start at least one sandbox session to populate this list.",
"sandboxPresetReadonly": "Sandbox preset skills are read-only here. You cannot delete or enable/disable them from Local Skills."
"sandboxPresetReadonly": "Sandbox preset skills are read-only here. You cannot delete or enable/disable them from Local Skills.",
"openEditor": "View/Edit",
"editorTitle": "Edit Skill",
"editorLoadFailed": "Failed to load Skill file",
"editorSaveFailed": "Failed to save Skill file",
"editorSaveSuccess": "Saved successfully",
"saveFile": "Save file",
"readonly": "Read-only",
"unsaved": "Unsaved",
"noFileSelected": "No file selected",
"discardChanges": "This file has unsaved changes. Discard them?"
},
"card": {
"actions": {

View File

@@ -1,6 +1,9 @@
{
"title": "Knowledge Base Details",
"backToList": "Back to List",
"breadcrumb": {
"list": "Knowledge Bases"
},
"tabs": {
"overview": "Overview",
"documents": "Documents",

View File

@@ -2,7 +2,7 @@
"title": "Knowledge Base Management",
"subtitle": "Manage and query knowledge base contents",
"list": {
"title": "My Knowledge Bases",
"title": "Knowledge Bases",
"subtitle": "Manage all your knowledge base collections",
"create": "Create Knowledge Base",
"refresh": "Refresh List",

View File

@@ -12,6 +12,7 @@
},
"mcpServers": {
"title": "MCP Servers",
"description": "Manage MCP servers",
"buttons": {
"refresh": "Refresh",
"add": "Add Server",

View File

@@ -52,6 +52,8 @@
"selectFile": "Выбрать файл",
"refresh": "Обновить",
"updateAll": "Обновить все",
"pin": "Закрепить",
"unpin": "Открепить",
"deleteSource": "Удалить источник",
"reshuffle": "Мне повезет!"
},
@@ -91,7 +93,27 @@
},
"empty": {
"noPlugins": "Плагины не найдены",
"noPluginsDesc": "Попробуйте установить новые плагины или включите отображение системных."
"noPluginsDesc": "Попробуйте установить новые плагины или изменить поиск."
},
"detail": {
"contents": "Обработчики плагина",
"noContents": "Нет обработчиков",
"notFound": "Плагин не найден",
"docsTitle": "Документация",
"docsEmpty": "Документация отсутствует",
"handlerGroups": {
"commands": "Команды / группы команд",
"hooks": "Хуки",
"functionTools": "Функциональные инструменты",
"eventListeners": "Слушатели событий"
},
"info": {
"title": "Информация",
"author": "Автор",
"category": "Категория",
"authorWebsite": "Сайт автора",
"repository": "Репозиторий"
}
},
"market": {
"recommended": "🥳 Рекомендуем",
@@ -143,8 +165,7 @@
"updated": "Дате обновления",
"updateStatus": "Статусу обновления",
"ascending": "По возрастанию",
"descending": "По убыванию",
"pinUpdatesOnTop": "Обновления сверху"
"descending": "По убыванию"
},
"tags": {
"danger": "Опасно"
@@ -222,6 +243,7 @@
"supportedFormats": "Поддерживаются файлы плагинов в формате .zip",
"updateAllSuccess": "Все плагины успешно обновлены",
"updateAllFailed": "Ошибок при обновлении: {failed} из {total}:",
"noUpdatesAvailable": "Нет плагинов с доступными обновлениями",
"fillSourceNameAndUrl": "Пожалуйста, введите имя и адрес источника",
"invalidUrl": "Введите корректный URL",
"enterJsonUrl": "Введите URL, возвращающий список плагинов в формате JSON"
@@ -327,7 +349,17 @@
"sourceSandboxOnly": "Предустановленный Sandbox навык",
"sourceBoth": "Локальный + Sandbox",
"sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.",
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь."
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь.",
"openEditor": "Просмотр/правка",
"editorTitle": "Редактировать навык",
"editorLoadFailed": "Не удалось открыть файл навыка",
"editorSaveFailed": "Не удалось сохранить файл навыка",
"editorSaveSuccess": "Сохранено",
"saveFile": "Сохранить файл",
"readonly": "Только чтение",
"unsaved": "Не сохранено",
"noFileSelected": "Файл не выбран",
"discardChanges": "В файле есть несохраненные изменения. Отменить их?"
},
"card": {
"actions": {

View File

@@ -1,6 +1,9 @@
{
"title": "Детали базы знаний",
"backToList": "К списку",
"breadcrumb": {
"list": "Базы знаний"
},
"tabs": {
"overview": "Обзор",
"documents": "Документы",

View File

@@ -2,7 +2,7 @@
"title": "Управление базами знаний",
"subtitle": "Централизованное управление всеми знаниями AstrBot",
"list": {
"title": "Мои базы знаний",
"title": "Базы знаний",
"subtitle": "Все доступные коллекции знаний",
"create": "Создать базу",
"refresh": "Обновить",
@@ -65,4 +65,4 @@
"deleteFailed": "Ошибка удаления",
"loadError": "Не удалось загрузить список"
}
}
}

View File

@@ -12,6 +12,7 @@
},
"mcpServers": {
"title": "MCP Сервера",
"description": "Управление MCP-серверами",
"buttons": {
"refresh": "Обновить",
"add": "Добавить сервер",

View File

@@ -9,8 +9,7 @@
"handlersOperation": "管理行为"
},
"titles": {
"installedAstrBotPlugins": "已安装的 AstrBot 插件",
"pinnedPlugins": "置顶插件"
"installedAstrBotPlugins": "已安装的 AstrBot 插件"
},
"failedPlugins": {
"title": "加载失败插件({count}",
@@ -53,9 +52,9 @@
"selectFile": "选择文件",
"refresh": "刷新",
"updateAll": "更新全部插件",
"deleteSource": "删除源",
"pin": "置顶",
"unpin": "取消置顶",
"deleteSource": "删除源",
"reshuffle": "随机一发"
},
"status": {
@@ -94,7 +93,27 @@
},
"empty": {
"noPlugins": "暂无插件",
"noPluginsDesc": "尝试安装插件或者显示系统插件"
"noPluginsDesc": "尝试安装插件或调整搜索条件"
},
"detail": {
"contents": "插件行为",
"noContents": "暂无行为",
"notFound": "未找到该插件",
"docsTitle": "文档",
"docsEmpty": "暂无文档",
"handlerGroups": {
"commands": "指令/指令组",
"hooks": "钩子",
"functionTools": "函数工具",
"eventListeners": "事件监听器"
},
"info": {
"title": "信息",
"author": "作者",
"category": "类别",
"authorWebsite": "作者网站",
"repository": "仓库"
}
},
"market": {
"recommended": "🥳 推荐",
@@ -147,8 +166,7 @@
"updated": "更新时间",
"updateStatus": "更新状态",
"ascending": "升序",
"descending": "降序",
"pinUpdatesOnTop": "有更新置顶"
"descending": "降序"
},
"tags": {
"danger": "危险"
@@ -226,6 +244,7 @@
"supportedFormats": "支持 .zip 格式的插件文件",
"updateAllSuccess": "所有可更新的插件都已更新!",
"updateAllFailed": "有 {failed}/{total} 个插件更新失败:",
"noUpdatesAvailable": "当前没有可更新的插件",
"fillSourceNameAndUrl": "请填写完整的插件源名称和地址",
"invalidUrl": "请输入有效的URL地址",
"enterJsonUrl": "请输入返回插件列表JSON数据的URL地址"
@@ -331,7 +350,17 @@
"sourceSandboxOnly": "Sandbox 预置 Skill",
"sourceBoth": "本地 + Sandbox",
"sandboxDiscoveryPending": "尚未发现 Sandbox 预置 Skill。请至少启动一次 Sandbox 会话后再查看。",
"sandboxPresetReadonly": "Sandbox 预置 Skill 在此处为只读,无法在本地 Skills 页面删除或启用/禁用。"
"sandboxPresetReadonly": "Sandbox 预置 Skill 在此处为只读,无法在本地 Skills 页面删除或启用/禁用。",
"openEditor": "查看/编辑",
"editorTitle": "编辑 Skill",
"editorLoadFailed": "读取 Skill 文件失败",
"editorSaveFailed": "保存 Skill 文件失败",
"editorSaveSuccess": "保存成功",
"saveFile": "保存文件",
"readonly": "只读",
"unsaved": "未保存",
"noFileSelected": "未选择文件",
"discardChanges": "当前文件有未保存修改,确定要丢弃吗?"
},
"card": {
"actions": {

View File

@@ -1,6 +1,9 @@
{
"title": "知识库详情",
"backToList": "返回列表",
"breadcrumb": {
"list": "知识库"
},
"tabs": {
"overview": "概览",
"documents": "文档管理",

View File

@@ -2,7 +2,7 @@
"title": "知识库管理",
"subtitle": "统一管理和查询知识库内容",
"list": {
"title": "我的知识库",
"title": "知识库",
"subtitle": "管理您的所有知识库集合",
"create": "创建知识库",
"refresh": "刷新列表",

View File

@@ -12,6 +12,7 @@
},
"mcpServers": {
"title": "MCP 服务器",
"description": "管理 MCP 服务器",
"buttons": {
"refresh": "刷新",
"add": "新增服务器",

View File

@@ -1,4 +1,7 @@
import { EXTENSION_ROUTE_NAME } from './routeConstants.mjs';
import {
EXTENSION_DETAILS_ROUTE_NAME,
EXTENSION_ROUTE_NAME
} from './routeConstants.mjs';
const MainRoutes = {
path: '/main',
@@ -23,6 +26,11 @@ const MainRoutes = {
path: '/extension',
component: () => import('@/views/ExtensionPage.vue')
},
{
name: EXTENSION_DETAILS_ROUTE_NAME,
path: '/extension/:pluginId',
component: () => import('@/views/ExtensionPage.vue')
},
{
name: 'ExtensionMarketplace',
path: '/extension-marketplace',

View File

@@ -1 +1,2 @@
export const EXTENSION_ROUTE_NAME = 'Extensions';
export const EXTENSION_DETAILS_ROUTE_NAME = 'ExtensionDetails';

View File

@@ -38,27 +38,18 @@
min-width: 0;
}
.dashboard-eyebrow {
margin-bottom: 8px;
color: var(--dashboard-subtle);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dashboard-title {
margin: 0;
font-size: clamp(32px, 4vw, 44px);
line-height: 1.04;
font-size: 1.5rem;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.04em;
letter-spacing: 0;
}
.dashboard-subtitle {
margin: 10px 0 0;
margin: 4px 0 0;
color: var(--dashboard-muted);
font-size: 15px;
font-size: 0.875rem;
line-height: 1.6;
max-width: 860px;
}

View File

@@ -7,20 +7,13 @@ const { tm } = useModuleI18n('features/console');
</script>
<template>
<div style="height: 100%;">
<div
style="background-color: var(--v-theme-surface); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px; display: flex; flex-direction: row; align-items: center; justify-content: space-between;">
<div class="console-page">
<div class="console-header">
<div>
<h4>{{ tm('title') }}</h4>
<v-alert
type="info"
variant="tonal"
density="compact"
class="mt-2"
style="max-width: 600px;"
>
<h1 class="text-h2 mb-1">{{ tm('title') }}</h1>
<p class="text-body-2 text-medium-emphasis mb-0">
{{ tm('debugHint.text') }}
</v-alert>
</p>
</div>
<div class="d-flex align-center">
<v-switch
@@ -28,6 +21,7 @@ const { tm } = useModuleI18n('features/console');
:label="autoScrollEnabled ? tm('autoScroll.enabled') : tm('autoScroll.disabled')"
hide-details
density="compact"
inset
color="primary"
style="margin-right: 16px;"
></v-switch>
@@ -58,7 +52,7 @@ const { tm } = useModuleI18n('features/console');
</v-dialog>
</div>
</div>
<ConsoleDisplayer ref="consoleDisplayer" style="height: calc(100vh - 220px); " />
<ConsoleDisplayer ref="consoleDisplayer" class="console-display" />
</div>
</template>
<script>
@@ -108,7 +102,27 @@ export default {
</script>
<style>
<style scoped>
.console-page {
height: 100%;
margin: 0 auto;
max-width: 1400px;
padding: 24px;
width: 100%;
}
.console-header {
align-items: flex-start;
display: flex;
justify-content: space-between;
margin-bottom: 24px;
}
.console-display {
height: calc(100vh - 190px);
width: 100%;
}
@keyframes fadeIn {
from {
opacity: 0;
@@ -122,4 +136,15 @@ export default {
.fade-in {
animation: fadeIn 0.2s ease-in-out;
}
@media (max-width: 768px) {
.console-page {
padding: 16px;
}
.console-header {
flex-direction: column;
gap: 12px;
}
}
</style>

View File

@@ -3,7 +3,6 @@
<v-container fluid class="dashboard-shell pa-4 pa-md-6">
<div class="dashboard-header">
<div class="dashboard-header-main">
<div class="dashboard-eyebrow">{{ tm('header.eyebrow') }}</div>
<div class="d-flex align-center flex-wrap" style="gap: 8px;">
<h1 class="dashboard-title">{{ tm('page.title') }}</h1>
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>
@@ -25,21 +24,6 @@
</div>
</div>
<div class="dashboard-overview-grid">
<section
v-for="card in overviewCards"
:key="card.label"
class="dashboard-card dashboard-overview-card"
>
<div class="dashboard-card-icon">
<v-icon size="18">{{ card.icon }}</v-icon>
</div>
<div class="dashboard-card-label">{{ card.label }}</div>
<div class="dashboard-card-value">{{ card.value }}</div>
<div class="dashboard-card-note">{{ card.note }}</div>
</section>
</div>
<div class="dashboard-section-head">
<div>
<div class="dashboard-section-title">{{ tm('section.platforms.title') }}</div>
@@ -262,10 +246,6 @@ const proactivePlatformText = computed(() =>
proactivePlatforms.value.map((p) => `${p.display_name || p.name}(${p.id})`).join(' / ')
)
const enabledJobsCount = computed(() => jobs.value.filter((job) => job.enabled).length)
const runOnceCount = computed(() => jobs.value.filter((job) => job.run_once).length)
const recurringCount = computed(() => jobs.value.filter((job) => !job.run_once).length)
const sortedJobs = computed(() =>
[...jobs.value].sort((a, b) => {
if (a.enabled !== b.enabled) {
@@ -285,21 +265,6 @@ const sortedJobs = computed(() =>
})
)
const overviewCards = computed(() => [
{
label: tm('overview.totalTasks'),
value: String(jobs.value.length),
note: tm('overview.totalTasksNote'),
icon: 'mdi-calendar-multiple'
},
{
label: tm('overview.enabledTasks'),
value: String(enabledJobsCount.value),
note: tm('overview.enabledTasksNote'),
icon: 'mdi-check-circle-outline'
}
])
const isEditing = computed(() => !!editingJobId.value)
const dialogTitle = computed(() => tm(isEditing.value ? 'form.editTitle' : 'form.title'))
const dialogSubmitText = computed(() => tm(isEditing.value ? 'actions.save' : 'actions.submit'))
@@ -720,10 +685,6 @@ onMounted(() => {
font-size: 14px;
}
.cron-page :deep(.dashboard-overview-grid) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 900px) {
.table-actions {
justify-items: start;

View File

@@ -9,7 +9,9 @@ import SkillsSection from "@/components/extension/SkillsSection.vue";
import ComponentPanel from "@/components/extension/componentPanel/index.vue";
import InstalledPluginsTab from "./extension/InstalledPluginsTab.vue";
import MarketPluginsTab from "./extension/MarketPluginsTab.vue";
import PluginDetailPage from "./extension/PluginDetailPage.vue";
import { useExtensionPage } from "./extension/useExtensionPage";
import { computed } from "vue";
const pageState = useExtensionPage();
@@ -150,10 +152,63 @@ const {
handleLocaleChange,
searchDebounceTimer,
} = pageState;
const selectedPluginId = computed(() => {
const pluginId = route.params.pluginId;
return Array.isArray(pluginId) ? pluginId[0] : pluginId || "";
});
const selectedInstalledPlugin = computed(() => {
if (!selectedPluginId.value) return null;
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
return data.find((plugin) => plugin.name === selectedPluginId.value) || null;
});
const selectedMarketPlugin = computed(() => {
const plugin = selectedInstalledPlugin.value;
if (!plugin) return null;
const market = Array.isArray(pluginMarketData.value) ? pluginMarketData.value : [];
const repo = plugin.repo?.toLowerCase();
return (
market.find((item) => repo && item.repo?.toLowerCase() === repo) ||
market.find((item) => item.name === plugin.name) ||
null
);
});
</script>
<template>
<v-row class="extension-page">
<PluginDetailPage
v-if="selectedPluginId && selectedInstalledPlugin"
:plugin="selectedInstalledPlugin"
:market-plugin="selectedMarketPlugin"
:state="pageState"
/>
<div v-else-if="selectedPluginId && loading_" class="pa-4">
<v-progress-linear indeterminate color="primary" />
</div>
<div v-else-if="selectedPluginId" class="pa-4">
<div class="d-flex align-center mb-6">
<v-btn
icon="mdi-arrow-left"
variant="text"
density="comfortable"
@click="router.push({ name: 'Extensions', hash: '#installed' })"
/>
<h2 class="text-h3 mb-0 ml-2">
{{ tm("titles.installedAstrBotPlugins") }}
<v-icon icon="mdi-chevron-right" size="24" class="mx-1" />
{{ selectedPluginId }}
</h2>
</div>
<v-alert type="warning" variant="tonal">
{{ tm("detail.notFound") }}
</v-alert>
</div>
<v-row v-else class="extension-page">
<v-col cols="12" md="12">
<v-card variant="flat" style="background-color: transparent">
<!-- 标签页 -->
@@ -182,8 +237,11 @@ const {
<!-- 已安装的 MCP 服务器标签页内容 -->
<v-tab-item v-if="activeTab === 'mcp'">
<div class="mb-4 pt-4 pb-4">
<div class="d-flex align-center flex-wrap" style="gap: 12px">
<div class="d-flex flex-column" style="gap: 6px">
<h2 class="text-h2 mb-0">{{ tm("tabs.installedMcpServers") }}</h2>
<div class="text-body-2 text-medium-emphasis">
{{ t("features.tooluse.mcpServers.description") }}
</div>
</div>
</div>
<v-card
@@ -200,8 +258,11 @@ const {
<!-- Skills 标签页内容 -->
<v-tab-item v-if="activeTab === 'skills'">
<div class="mb-4 pt-4 pb-4">
<div class="d-flex align-center flex-wrap" style="gap: 12px">
<div class="d-flex flex-column" style="gap: 6px">
<h2 class="text-h2 mb-0">{{ tm("tabs.skills") }}</h2>
<div class="text-body-2 text-medium-emphasis">
{{ tm("skills.runtimeHint") }}
</div>
</div>
</div>
<v-card

View File

@@ -2,12 +2,12 @@
<div class="persona-page">
<v-container fluid class="pa-0">
<!-- 页面标题 -->
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-6">
<v-row class="d-flex justify-space-between align-center py-3 pb-6">
<div>
<h1 class="text-h1 font-weight-bold mb-2">
<v-icon class="me-2">mdi-heart</v-icon>{{ t('core.navigation.persona') }}
<h1 class="text-h2 mb-1">
{{ t('core.navigation.persona') }}
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-0">
<p class="text-body-2 text-medium-emphasis mb-0">
{{ tm('page.description') }}
</p>
</div>
@@ -38,7 +38,15 @@ export default {
<style scoped>
.persona-page {
padding: 20px;
padding-top: 8px;
margin: 0 auto;
max-width: 1400px;
padding: 24px;
width: 100%;
}
@media (max-width: 768px) {
.persona-page {
padding: 16px;
}
}
</style>

View File

@@ -3,7 +3,6 @@
<v-container fluid class="dashboard-shell pa-4 pa-md-6">
<div class="dashboard-header">
<div class="dashboard-header-main">
<div class="dashboard-eyebrow">{{ tm('header.eyebrow') }}</div>
<div class="d-flex align-center flex-wrap" style="gap: 8px;">
<h1 class="dashboard-title">{{ tm('page.title') }}</h1>
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>

View File

@@ -1,11 +1,14 @@
<script setup>
import TraceDisplayer from '@/components/shared/TraceDisplayer.vue';
import { useModuleI18n } from '@/i18n/composables';
import { ref, onMounted } from 'vue';
import { computed, ref, onMounted } from 'vue';
import { useTheme } from 'vuetify';
import axios from 'axios';
const { tm } = useModuleI18n('features/trace');
const theme = useTheme();
const isDark = computed(() => theme.global.current.value.dark);
const traceEnabled = ref(true);
const loading = ref(false);
const traceDisplayerKey = ref(0);
@@ -42,31 +45,36 @@ onMounted(() => {
</script>
<template>
<div style="height: 100%; display: flex; flex-direction: column;">
<div class="trace-header">
<div class="trace-info">
<v-icon size="small" color="info" class="mr-2">mdi-information-outline</v-icon>
<span class="trace-hint">{{ tm('hint') }}</span>
<div class="dashboard-page trace-page" :class="{ 'is-dark': isDark }">
<v-container fluid class="dashboard-shell trace-shell pa-4 pa-md-6">
<div class="dashboard-header trace-header">
<div class="dashboard-header-main">
<h1 class="dashboard-title">{{ tm('title') }}</h1>
<p class="dashboard-subtitle">
{{ tm('hint') }}
</p>
</div>
<div class="dashboard-header-actions">
<v-switch
v-model="traceEnabled"
:loading="loading"
:disabled="loading"
color="primary"
hide-details
density="compact"
inset
@update:model-value="updateTraceSettings"
>
<template #label>
<span class="switch-label">{{ traceEnabled ? tm('recording') : tm('paused') }}</span>
</template>
</v-switch>
</div>
</div>
<div class="trace-controls">
<v-switch
v-model="traceEnabled"
:loading="loading"
:disabled="loading"
color="primary"
hide-details
density="compact"
@update:model-value="updateTraceSettings"
>
<template #label>
<span class="switch-label">{{ traceEnabled ? tm('recording') : tm('paused') }}</span>
</template>
</v-switch>
<div class="trace-body">
<TraceDisplayer :key="traceDisplayerKey" />
</div>
</div>
<div style="flex: 1; min-height: 0;">
<TraceDisplayer :key="traceDisplayerKey" />
</div>
</v-container>
</div>
</template>
@@ -80,36 +88,36 @@ export default {
</script>
<style scoped>
@import '@/styles/dashboard-shell.css';
.trace-page,
.trace-shell {
height: 100%;
}
.trace-shell {
display: flex;
flex-direction: column;
}
.trace-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(59, 130, 246, 0.05);
border-bottom: 1px solid rgba(59, 130, 246, 0.1);
border-radius: 8px 8px 0 0;
margin-bottom: 8px;
flex: 0 0 auto;
}
.trace-info {
display: flex;
align-items: center;
}
.trace-hint {
font-size: 13px;
color: #6b7280;
}
.trace-controls {
display: flex;
align-items: center;
gap: 8px;
.trace-body {
flex: 1 1 auto;
min-height: 0;
}
.switch-label {
color: var(--dashboard-muted);
font-size: 13px;
color: #4b5563;
white-space: nowrap;
}
@media (max-width: 768px) {
.trace-header {
align-items: flex-start;
}
}
</style>

View File

@@ -1,11 +1,11 @@
<script setup>
import PluginSortControl from "@/components/extension/PluginSortControl.vue";
import PinnedPluginItem from "@/components/extension/PinnedPluginItem.vue";
import ExtensionCard from "@/components/shared/ExtensionCard.vue";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
import { normalizeTextInput } from "@/utils/inputValue";
import { ref, computed, watch } from "vue";
import {
readPinnedExtensions,
writePinnedExtensions,
} from "./extensionPreferenceStorage.mjs";
import { computed, ref, watch } from "vue";
const props = defineProps({
state: {
@@ -31,9 +31,6 @@ const {
getLocationHash,
extractTabFromHash,
syncTabFromHash,
extension_data,
getInitialShowReserved,
showReserved,
snack_message,
snack_show,
snack_success,
@@ -49,14 +46,7 @@ const {
forceUpdateDialog,
updateAllConfirmDialog,
changelogDialog,
getInitialListViewMode,
isListView,
pluginSearch,
installedStatusFilter,
installedSortBy,
installedSortOrder,
pinUpdatesOnTop,
loading_,
currentPage,
dangerConfirmDialog,
selectedDangerPlugin,
@@ -90,9 +80,6 @@ const {
toPinyinText,
toInitials,
plugin_handler_info_headers,
installedSortItems,
installedSortUsesOrder,
pluginHeaders,
filteredExtensions,
filteredPlugins,
filteredMarketPlugins,
@@ -105,7 +92,6 @@ const {
totalPages,
paginatedPlugins,
updatableExtensions,
toggleShowReserved,
toast,
resetLoadingDialog,
onLoadingDialogResult,
@@ -159,107 +145,61 @@ const {
searchDebounceTimer,
} = props.state;
// 置顶插件(保存在 localStorage
const PINNED_KEY = "astrbot.pinnedExtensions";
const pinnedNames = ref([]);
const loadPinned = () => {
try {
const raw = localStorage.getItem(PINNED_KEY);
pinnedNames.value = raw ? JSON.parse(raw) : [];
} catch (e) {
pinnedNames.value = [];
}
const openPluginDetail = (extension) => {
if (!extension?.name) return;
router.push({
name: "ExtensionDetails",
params: { pluginId: extension.name },
hash: "#installed",
});
};
const savePinned = () => {
try {
localStorage.setItem(PINNED_KEY, JSON.stringify(pinnedNames.value || []));
} catch (e) {
// ignore
}
const pinnedExtensionNames = ref(readPinnedExtensions());
const pinnedExtensionOrder = computed(() => {
const order = new Map();
pinnedExtensionNames.value.forEach((name, index) => {
order.set(name, index);
});
return order;
});
const sortedInstalledPlugins = computed(() => {
const order = pinnedExtensionOrder.value;
return [...filteredPlugins.value].sort((a, b) => {
const aIndex = order.has(a?.name) ? order.get(a.name) : Number.POSITIVE_INFINITY;
const bIndex = order.has(b?.name) ? order.get(b.name) : Number.POSITIVE_INFINITY;
if (aIndex !== bIndex) {
return aIndex - bIndex;
}
return 0;
});
});
watch(
pinnedExtensionNames,
(names) => {
writePinnedExtensions(names);
},
{ deep: true },
);
const isPinnedExtension = (extension) => {
const name = extension?.name;
return !!name && pinnedExtensionOrder.value.has(name);
};
loadPinned();
watch(pinnedNames, () => savePinned(), { deep: true });
const isPinned = (name) => {
return pinnedNames.value.includes(name);
};
const togglePin = (extension) => {
const togglePinnedExtension = (extension) => {
const name = extension?.name;
if (!name) return;
const idx = pinnedNames.value.indexOf(name);
if (idx === -1) pinnedNames.value.push(name);
else pinnedNames.value.splice(idx, 1);
};
const handlePinnedImgError = (e) => {
e.target.src = defaultPluginIcon;
};
// --- 拖拽功能实现 ---
const draggedIndex = ref(-1);
let lastSwapTime = 0;
const onDragStart = (index) => {
draggedIndex.value = index;
};
const onDragOver = (e) => {
e.preventDefault(); // 必须调用,否则不会触发 drop
};
const onDragEnter = (e, index) => {
e.preventDefault();
const now = Date.now();
if (now - lastSwapTime < 100) return; // 100ms 冷却,防止快速抖动
if (draggedIndex.value === -1 || draggedIndex.value === index) {
return;
const next = pinnedExtensionNames.value.filter((item) => item !== name);
if (next.length === pinnedExtensionNames.value.length) {
next.unshift(name);
}
const newList = [...pinnedNames.value];
const item = newList.splice(draggedIndex.value, 1)[0];
newList.splice(index, 0, item);
pinnedNames.value = newList;
draggedIndex.value = index;
lastSwapTime = now;
pinnedExtensionNames.value = next;
};
const onDrop = () => {
draggedIndex.value = -1;
};
const onDragEnd = () => {
draggedIndex.value = -1;
};
// ----------------
// 映射 name -> plugin 对象(优先从 sortedPlugins 找)
const pinnedPlugins = computed(() => {
if (!Array.isArray(pinnedNames.value)) return [];
const installedAll = Array.isArray(extension_data?.data) ? extension_data.data : [];
const all = Array.isArray(sortedPlugins?.value) ? sortedPlugins.value : [];
const filtered = Array.isArray(filteredPlugins?.value) ? filteredPlugins.value : [];
const market = Array.isArray(pluginMarketData?.value) ? pluginMarketData.value : [];
const findByName = (name) => {
return (
installedAll.find((p) => p.name === name) ||
all.find((p) => p.name === name) ||
filtered.find((p) => p.name === name) ||
market.find((p) => p.name === name)
);
};
return pinnedNames.value.map((name) => findByName(name)).filter(Boolean);
});
</script>
<template>
@@ -284,134 +224,10 @@ const pinnedPlugins = computed(() => {
>
</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">
<div class="installed-toolbar">
<div class="installed-toolbar__actions">
<v-btn variant="tonal" @click="toggleShowReserved">
<v-icon>{{
showReserved ? "mdi-eye-off" : "mdi-eye"
}}</v-icon>
{{
showReserved
? tm("buttons.hideSystemPlugins")
: tm("buttons.showSystemPlugins")
}}
</v-btn>
<v-btn
color="warning"
variant="tonal"
:disabled="updatableExtensions.length === 0"
:loading="updatingAll"
@click="showUpdateAllConfirm"
>
<v-icon>mdi-update</v-icon>
{{ tm("buttons.updateAll") }}
</v-btn>
</div>
<div class="installed-toolbar__controls">
<v-btn-toggle
v-model="installedStatusFilter"
mandatory
divided
density="compact"
color="primary"
class="installed-status-toggle"
>
<v-btn value="all" prepend-icon="mdi-filter-variant">
{{ tm("filters.all") }}
</v-btn>
<v-btn value="enabled" prepend-icon="mdi-play-circle-outline">
{{ tm("status.enabled") }}
</v-btn>
<v-btn value="disabled" prepend-icon="mdi-pause-circle-outline">
{{ tm("status.disabled") }}
</v-btn>
</v-btn-toggle>
<PluginSortControl
v-model="installedSortBy"
:items="installedSortItems"
:label="tm('sort.by')"
:order="installedSortOrder"
:ascending-label="tm('sort.ascending')"
:descending-label="tm('sort.descending')"
:show-order="installedSortUsesOrder"
@update:order="installedSortOrder = $event"
/>
<v-switch
v-model="pinUpdatesOnTop"
:label="tm('sort.pinUpdatesOnTop')"
color="primary"
density="compact"
hide-details
class="ml-4"
style="max-width: 200px"
/>
</div>
</div>
</v-col>
</v-row>
<!-- 置顶插件列表 -->
<v-row v-if="pinnedPlugins.length > 0" class="mb-4">
<v-col cols="12">
<v-card class="rounded-lg overflow-hidden elevation-0" variant="flat">
<v-card-text class="pa-4">
<div class="d-flex align-center justify-space-between">
<h3 class="text-h6 mb-0">{{ tm('titles.pinnedPlugins') }}</h3>
</div>
<v-row class="mt-3 relative" dense align="center" style="gap:12px">
<transition-group name="list" class="v-row v-row--dense">
<v-col
cols="auto"
v-for="(p, index) in pinnedPlugins"
:key="p.name"
>
<PinnedPluginItem
:plugin="p"
:is-pinned="isPinned(p.name)"
:tm="tm"
:dragged="draggedIndex === index"
@toggle-pin="togglePin"
@view-readme="viewReadme"
@open-config="openExtensionConfig"
@reload="reloadPlugin"
@update="updateExtension"
@show-info="showPluginInfo"
@uninstall="uninstallExtension"
@dragstart="onDragStart(index)"
@dragover="onDragOver($event)"
@dragenter="onDragEnter($event, index)"
@dragend="onDragEnd($event)"
@drop="onDrop($event)"
/>
</v-col>
</transition-group>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-card
v-if="failedPluginItems.length > 0"
class="mb-4 rounded-lg"
@@ -487,266 +303,8 @@ const pinnedPlugins = computed(() => {
</v-card>
<v-fade-transition hide-on-leave>
<!-- 表格视图 -->
<div v-if="isListView">
<v-card class="rounded-lg overflow-hidden elevation-0">
<v-data-table
class="plugin-list-table"
:headers="pluginHeaders"
:items="filteredPlugins"
:loading="loading_"
item-key="name"
hover
>
<template v-slot:item.name="{ item }">
<div class="d-flex">
<div class="mr-3" style="flex-shrink: 0">
<img
:src="(typeof item.logo === 'string' && item.logo.trim()) ? item.logo : defaultPluginIcon"
:alt="item.name"
style="height: 40px; width: 40px; border-radius: 8px; object-fit: cover;"
/>
</div>
<div>
<div class="text-h5" style="font-family: inherit;">
{{ 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-tooltip v-if="item.has_update" location="top">
<template v-slot:activator="{ props: tooltipProps }">
<v-icon
v-bind="tooltipProps"
color="warning"
size="small"
class="ml-1"
style="cursor: pointer"
@click.stop="updateExtension(item.name)"
>mdi-alert</v-icon
>
</template>
<span
>{{ tm("messages.hasUpdate") }}
{{ item.online_version }}</span
>
</v-tooltip>
<v-tooltip v-if="item.has_update" location="top">
<template v-slot:activator="{ props: tooltipProps }">
<span
v-bind="tooltipProps"
class="ml-1 text-caption text-warning"
style="cursor: pointer"
@click.stop="updateExtension(item.name)"
>
{{ item.online_version }}
</span>
</template>
<span>{{ tm("buttons.update") }}</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 justify-start ga-2 py-1">
<v-btn
icon
size="small"
variant="tonal"
color="secondary"
class="table-action-btn pin-action"
@click.stop="togglePin(item)"
:title="isPinned(item.name) ? tm('buttons.unpin') : tm('buttons.pin')"
>
<v-icon size="18">{{ isPinned(item.name) ? 'mdi-pin' : 'mdi-pin-outline' }}</v-icon>
</v-btn>
<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">
<div>
<v-row v-if="sortedInstalledPlugins.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
@@ -762,17 +320,17 @@ const pinnedPlugins = computed(() => {
<v-col
cols="12"
md="6"
lg="4"
v-for="extension in filteredPlugins"
:key="extension.name"
class="pb-2"
>
<ExtensionCard
:extension="extension"
:pinned="isPinned(extension.name)"
@toggle-pin="() => togglePin(extension)"
:is-pinned="isPinnedExtension(extension)"
class="rounded-lg"
style="background-color: rgb(var(--v-theme-mcpCardBg))"
@click="openPluginDetail(extension)"
@toggle-pin="togglePinnedExtension(extension)"
@configure="openExtensionConfig(extension.name)"
@uninstall="
(ext, options) => uninstallExtension(ext.name, options)
@@ -821,74 +379,25 @@ const pinnedPlugins = computed(() => {
</button>
</template>
</v-tooltip>
<v-tooltip :text="tm('buttons.updateAll')" location="left">
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-update"
size="x-large"
variant="elevated"
class="update-all-fab"
:loading="updatingAll"
@click="showUpdateAllConfirm"
/>
</template>
</v-tooltip>
</v-tab-item>
</template>
<style scoped>
.installed-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.installed-toolbar__actions,
.installed-toolbar__controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.installed-toolbar__controls {
margin-left: auto;
justify-content: flex-end;
}
.installed-status-toggle :deep(.v-btn) {
min-height: 34px;
text-transform: none;
}
.view-mode-toggle :deep(.v-btn) {
min-width: 30px;
height: 28px;
padding: 0 8px;
}
.table-action-btn {
min-height: 32px;
font-size: 0.86rem;
font-weight: 600;
}
.table-action-row {
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
.plugin-list-table :deep(td) {
vertical-align: top;
}
@media (max-width: 1400px) {
.table-action-btn {
min-width: 0;
padding: 0 8px;
}
}
@media (max-width: 960px) {
.installed-toolbar__controls {
margin-left: 0;
width: 100%;
justify-content: flex-start;
}
}
.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);
@@ -899,93 +408,18 @@ const pinnedPlugins = computed(() => {
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
}
.pinned-plugins h3 {
font-weight: 600;
.update-all-fab {
position: fixed;
right: 52px;
bottom: 124px;
z-index: 10000;
border-radius: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.pinned-list {
gap: 12px;
}
.pinned-item {
flex: 1 1 180px;
max-width: 320px;
height: 76px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.04);
box-shadow: 0 1px 4px rgba(16,24,40,0.04);
}
.pinned-avatar {
display: inline-flex;
width: 100%;
height: 100%;
overflow: hidden;
cursor: pointer;
border-radius: 12px;
}
.pinned-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pinned-card-wrapper {
position: relative;
display: inline-block;
width: 72px;
height: 72px;
}
.pin-btn {
position: absolute;
top: 6px;
right: 6px;
z-index: 5;
}
.pinned-item-skeleton {
width: 72px;
height: 72px;
border-radius: 10px;
}
.pinned-item {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.is-dragging {
opacity: 0.5;
transform: scale(0.95);
cursor: grabbing;
}
[draggable="true"] {
cursor: grab;
}
[draggable="true"]:active {
cursor: grabbing;
}
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: scale(0.6);
}
.list-leave-active {
position: absolute;
.update-all-fab:hover {
transform: translateY(-4px) scale(1.05);
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
}
</style>

View File

@@ -0,0 +1,954 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import axios from "axios";
import DOMPurify from "dompurify";
import MarkdownIt from "markdown-it";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
const props = defineProps({
plugin: {
type: Object,
required: true,
},
marketPlugin: {
type: Object,
default: null,
},
state: {
type: Object,
required: true,
},
});
const { tm, router } = props.state;
const markdown = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: false,
});
markdown.enable(["table", "strikethrough"]);
const MARKDOWN_SANITIZE_OPTIONS = {
ALLOWED_TAGS: [
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p",
"br",
"hr",
"ul",
"ol",
"li",
"blockquote",
"pre",
"code",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"strong",
"em",
"del",
"s",
"div",
"span",
],
ALLOWED_ATTR: ["href", "src", "alt", "title", "target", "rel", "align"],
};
const readmeLoading = ref(false);
const readmeError = ref("");
const readmeEmpty = ref(false);
const renderedReadme = ref("");
const expandedCommandGroups = ref(new Set());
const logoLoadFailed = ref(false);
const detailPageRef = ref(null);
const isHeaderStuck = ref(false);
const displayName = computed(() =>
props.plugin.display_name?.length ? props.plugin.display_name : props.plugin.name,
);
const pluginDesc = computed(() => {
const desc =
props.plugin.desc ||
props.plugin.description ||
props.marketPlugin?.desc ||
props.marketPlugin?.description ||
"";
return String(desc || "").trim();
});
const logoSrc = computed(() => {
const logo = props.plugin?.logo || props.marketPlugin?.logo || "";
if (logoLoadFailed.value) {
return defaultPluginIcon;
}
return typeof logo === "string" && logo.trim().length ? logo : defaultPluginIcon;
});
const authorDisplay = computed(() => {
const plugin = props.plugin || {};
const marketPlugin = props.marketPlugin || {};
const author =
plugin.author ||
marketPlugin.author ||
plugin.author_name ||
marketPlugin.author_name ||
plugin.owner ||
marketPlugin.owner;
if (Array.isArray(author)) {
return author.join(", ");
}
if (author && typeof author === "object") {
return author.name || "";
}
return typeof author === "string" ? author.trim() : "";
});
const categoryDisplay = computed(() => {
const rawCategory = props.plugin.category || props.marketPlugin?.category || "";
const category = String(rawCategory || "").trim();
if (!category) return "";
const normalized = category.toLowerCase().replace(/\s+/g, "_");
const label = tm(`market.categories.${normalized}`);
return label === `market.categories.${normalized}` ? category : label;
});
const authorWebsite = computed(() => {
const plugin = props.plugin || {};
const marketPlugin = props.marketPlugin || {};
return (
plugin.social_link ||
marketPlugin.social_link ||
plugin.author_url ||
marketPlugin.author_url ||
plugin.homepage ||
marketPlugin.homepage ||
""
);
});
const repoUrl = computed(() => props.plugin.repo || props.marketPlugin?.repo || "");
const infoRows = computed(() => {
const rows = [
{ label: tm("detail.info.author"), value: authorDisplay.value },
{ label: tm("detail.info.category"), value: categoryDisplay.value, optional: true },
{
label: tm("detail.info.authorWebsite"),
value: authorWebsite.value,
href: authorWebsite.value,
optional: true,
},
{
label: tm("detail.info.repository"),
value: repoUrl.value,
href: repoUrl.value,
optional: true,
},
];
return rows.filter((row) => !row.optional || row.value);
});
const handlers = computed(() =>
Array.isArray(props.plugin.handlers) ? props.plugin.handlers : [],
);
const handlerGroupOrder = [
"commands",
"hooks",
"functionTools",
"eventListeners",
];
const handlerGroupIcons = {
commands: "mdi-console-line",
hooks: "mdi-hook",
functionTools: "mdi-tools",
eventListeners: "mdi-broadcast",
};
const getHandlerGroupKey = (handler) => {
const type = String(handler?.type || "").trim();
const eventType = String(handler?.event_type || "").trim();
const eventTypeH = String(handler?.event_type_h || "").trim();
if (["指令", "指令组", "正则匹配"].includes(type)) {
return "commands";
}
if (eventType === "OnCallingFuncToolEvent" || eventTypeH === "函数工具") {
return "functionTools";
}
if (type === "事件监听器") {
return "eventListeners";
}
return "hooks";
};
const groupedHandlerSections = computed(() => {
const groups = new Map(handlerGroupOrder.map((key) => [key, []]));
handlers.value.forEach((handler) => {
groups.get(getHandlerGroupKey(handler))?.push(handler);
});
return handlerGroupOrder
.map((key) => ({
key,
title: tm(`detail.handlerGroups.${key}`),
icon: handlerGroupIcons[key],
handlers: groups.get(key) || [],
}))
.filter((group) => group.handlers.length > 0);
});
const getHandlerCommand = (handler) =>
String(handler?.cmd || handler?.handler_name || tm("status.unknown")).trim();
const getHandlerDisplayName = (handler, groupKey) => {
if (["functionTools", "eventListeners"].includes(groupKey)) {
return handler?.handler_name || handler?.cmd || tm("status.unknown");
}
return handler?.cmd || handler?.handler_name || tm("status.unknown");
};
const getHandlerTiming = (handler) =>
String(handler?.event_type_h || handler?.event_type || "").trim();
const splitCommandPrefix = (handler) => {
const command = getHandlerCommand(handler);
const parts = command.split(/\s+/).filter(Boolean);
if (parts.length <= 1) {
return { prefix: command, childCommand: command };
}
return {
prefix: parts[0],
childCommand: parts.slice(1).join(" "),
};
};
const isCommandGroupExpanded = (key) => expandedCommandGroups.value.has(key);
const toggleCommandGroup = (key) => {
const next = new Set(expandedCommandGroups.value);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
expandedCommandGroups.value = next;
};
const buildCommandHandlerRows = (commandHandlers) => {
const buckets = new Map();
commandHandlers.forEach((handler, index) => {
const { prefix, childCommand } = splitCommandPrefix(handler);
const key = prefix || getHandlerCommand(handler);
if (!buckets.has(key)) {
buckets.set(key, {
key,
prefix,
firstIndex: index,
handlers: [],
});
}
buckets.get(key).handlers.push({
handler,
childCommand,
originalIndex: index,
});
});
return Array.from(buckets.values())
.sort((left, right) => left.firstIndex - right.firstIndex)
.flatMap((bucket) => {
if (bucket.handlers.length <= 1) {
const only = bucket.handlers[0];
return [
{
kind: "handler",
key: only.handler.handler_full_name || only.handler.handler_name || only.handler.cmd,
handler: only.handler,
displayCommand: getHandlerCommand(only.handler),
},
];
}
const groupRow = {
kind: "group",
key: bucket.key,
displayCommand: bucket.prefix,
children: bucket.handlers,
};
if (!isCommandGroupExpanded(bucket.key)) {
return [groupRow];
}
return [
groupRow,
...bucket.handlers.map(({ handler, childCommand }) => ({
kind: "subCommand",
key: handler.handler_full_name || handler.handler_name || handler.cmd,
handler,
displayCommand: childCommand,
})),
];
});
};
const openExternal = (url) => {
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
};
const goBack = () => {
router.push({ name: "Extensions", hash: "#installed" });
};
const renderMarkdown = (source) => {
const normalizedSource = String(source || "")
.replace(/[“”]/g, '"')
.replace(/[]/g, "'");
const rawHtml = markdown.render(normalizedSource);
const cleanHtml = DOMPurify.sanitize(rawHtml, MARKDOWN_SANITIZE_OPTIONS);
const container = document.createElement("div");
container.innerHTML = cleanHtml;
container.querySelectorAll("a").forEach((link) => {
const href = link.getAttribute("href") || "";
if (href.startsWith("http") || href.startsWith("//")) {
link.setAttribute("target", "_blank");
link.setAttribute("rel", "noopener noreferrer");
}
});
return container.innerHTML;
};
const updateHeaderStuckState = () => {
const scrollTop =
document.scrollingElement?.scrollTop ||
document.documentElement.scrollTop ||
window.scrollY ||
0;
isHeaderStuck.value = scrollTop > 0;
};
const fetchReadme = async () => {
if (!props.plugin?.name) return;
readmeLoading.value = true;
readmeError.value = "";
readmeEmpty.value = false;
renderedReadme.value = "";
try {
const res = await axios.get("/api/plugin/readme", {
params: { name: props.plugin.name },
});
if (res.data.status !== "ok") {
readmeError.value = res.data.message || tm("messages.operationFailed");
return;
}
const content = res.data.data?.content || "";
if (!content) {
readmeEmpty.value = true;
return;
}
renderedReadme.value = renderMarkdown(content);
} catch (err) {
readmeError.value = err?.message || String(err);
} finally {
readmeLoading.value = false;
}
};
watch(
() => props.plugin?.name,
() => {
logoLoadFailed.value = false;
fetchReadme();
},
{ immediate: true },
);
onMounted(() => {
updateHeaderStuckState();
window.addEventListener("scroll", updateHeaderStuckState, { passive: true });
document.addEventListener("scroll", updateHeaderStuckState, {
capture: true,
passive: true,
});
});
onBeforeUnmount(() => {
window.removeEventListener("scroll", updateHeaderStuckState);
document.removeEventListener("scroll", updateHeaderStuckState, {
capture: true,
});
});
</script>
<template>
<div ref="detailPageRef" class="plugin-detail-page">
<div
class="detail-header"
:class="{ 'detail-header--stuck': isHeaderStuck }"
>
<h2 class="detail-title">
<button class="detail-title__parent" type="button" @click="goBack">
{{ tm("titles.installedAstrBotPlugins") }}
</button>
<v-icon icon="mdi-chevron-right" size="24" class="mx-1" />
<span class="detail-title__current">{{ displayName }}</span>
</h2>
</div>
<v-card class="plugin-summary-card rounded-lg" variant="outlined">
<v-card-text class="plugin-summary-card__body">
<img
:src="logoSrc"
:alt="displayName"
class="plugin-summary-card__icon"
@error="logoLoadFailed = true"
/>
<h1 class="plugin-summary-card__title">{{ displayName }}</h1>
<p v-if="pluginDesc" class="plugin-summary-card__desc">
{{ pluginDesc }}
</p>
</v-card-text>
</v-card>
<section class="detail-section">
<h3 class="detail-section__title">{{ tm("detail.contents") }}</h3>
<div v-if="groupedHandlerSections.length" class="handler-groups">
<div
v-for="group in groupedHandlerSections"
:key="group.key"
class="handler-group"
>
<div class="handler-group__title">
<v-icon :icon="group.icon" size="20" />
{{ group.title }}
<span class="handler-group__count">{{ group.handlers.length }}</span>
</div>
<v-card class="rounded-lg handler-card" variant="outlined">
<v-table
v-if="group.key === 'commands'"
class="detail-info-table detail-handler-table"
>
<tbody>
<tr
v-for="item in buildCommandHandlerRows(group.handlers)"
:key="item.key"
:class="{
'command-row--group': item.kind === 'group',
'command-row--sub': item.kind === 'subCommand',
}"
>
<td class="detail-info-table__label detail-handler-table__name">
<div class="command-cell">
<v-btn
v-if="item.kind === 'group'"
icon
variant="text"
size="x-small"
class="command-cell__toggle"
@click="toggleCommandGroup(item.key)"
>
<v-icon size="18">
{{
isCommandGroupExpanded(item.key)
? "mdi-chevron-down"
: "mdi-chevron-right"
}}
</v-icon>
</v-btn>
<span
v-else-if="item.kind === 'subCommand'"
class="command-cell__indent"
></span>
<code
:class="{
'command-code--group': item.kind === 'group',
'command-code--sub': item.kind === 'subCommand',
}"
>
{{ item.displayCommand }}
</code>
</div>
</td>
<td>
<div class="handler-row__desc">
<template v-if="item.kind === 'group'">
{{ tm("detail.subCommandsCount", { count: item.children.length }) }}
</template>
<template v-else>
{{ item.handler.desc || tm("status.unknown") }}
</template>
</div>
</td>
</tr>
</tbody>
</v-table>
<v-table v-else class="detail-info-table detail-handler-table">
<tbody>
<tr
v-for="handler in group.handlers"
:key="handler.handler_full_name || handler.handler_name || handler.cmd"
>
<td class="detail-info-table__label detail-handler-table__name">
<div>
{{ getHandlerDisplayName(handler, group.key) }}
</div>
</td>
<td>
<div class="handler-row__desc">
<span
v-if="group.key === 'hooks' && getHandlerTiming(handler)"
class="handler-row__timing"
>
{{ getHandlerTiming(handler) }}
</span>
<span>{{ handler.desc || tm("status.unknown") }}</span>
</div>
</td>
</tr>
</tbody>
</v-table>
</v-card>
</div>
</div>
<v-card v-else class="rounded-lg handler-card" variant="outlined">
<v-card-text class="pa-4 text-medium-emphasis">
{{ tm("detail.noContents") }}
</v-card-text>
</v-card>
</section>
<section class="detail-section">
<h3 class="detail-section__title">{{ tm("detail.info.title") }}</h3>
<v-card class="rounded-lg" variant="outlined">
<v-table class="detail-info-table">
<tbody>
<tr v-for="row in infoRows" :key="row.label">
<td class="detail-info-table__label">{{ row.label }}</td>
<td>
<v-btn
v-if="row.action"
color="primary"
variant="text"
density="comfortable"
prepend-icon="mdi-book-open-page-variant"
@click="row.action"
>
{{ row.actionText }}
</v-btn>
<button
v-else-if="row.href"
class="detail-link"
type="button"
@click="openExternal(row.href)"
>
<span>{{ row.value }}</span>
<v-icon icon="mdi-open-in-new" size="16" />
</button>
<span v-else>{{ row.value || tm("status.unknown") }}</span>
</td>
</tr>
</tbody>
</v-table>
</v-card>
</section>
<section class="detail-section">
<h3 class="detail-section__title">{{ tm("detail.docsTitle") }}</h3>
<v-card class="rounded-lg docs-card" variant="outlined">
<v-card-text>
<div v-if="readmeLoading" class="docs-state">
<v-progress-circular indeterminate color="primary" />
</div>
<v-alert v-else-if="readmeError" type="error" variant="tonal">
{{ readmeError }}
</v-alert>
<div v-else-if="readmeEmpty" class="text-medium-emphasis">
{{ tm("detail.docsEmpty") }}
</div>
<div
v-else
class="docs-markdown"
v-html="renderedReadme"
></div>
</v-card-text>
</v-card>
</section>
</div>
</template>
<style scoped>
.plugin-detail-page {
margin: 0 auto;
max-width: 1040px;
padding: 16px 24px 32px;
width: 100%;
}
.detail-header {
align-items: center;
display: flex;
gap: 8px;
isolation: isolate;
margin-bottom: 28px;
padding: 10px 0;
position: sticky;
top: calc(var(--v-layout-top, 64px));
z-index: 20;
}
.detail-header--stuck::before {
background: rgb(var(--v-theme-surface));
box-shadow: 0 1px 0 rgba(var(--v-border-color), var(--v-border-opacity));
content: "";
inset: 0 calc(50% - 50vw);
position: absolute;
z-index: -1;
}
.detail-title {
align-items: center;
display: flex;
flex-wrap: wrap;
font-size: 1.5rem;
font-weight: 700;
gap: 2px;
letter-spacing: 0;
margin: 0;
min-width: 0;
}
.detail-title__parent {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
font: inherit;
padding: 0;
}
.detail-title__parent:hover {
color: rgb(var(--v-theme-primary));
}
.detail-title__current {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plugin-summary-card {
background: rgb(var(--v-theme-surface));
color: rgba(var(--v-theme-on-surface), 0.9);
}
.plugin-summary-card__body {
align-items: flex-start;
display: flex;
flex-direction: column;
gap: 10px;
padding: 24px;
}
.plugin-summary-card__icon {
border-radius: 16px;
height: 72px;
object-fit: cover;
width: 72px;
}
.plugin-summary-card__title {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: 0;
line-height: 1.25;
margin: 0;
}
.plugin-summary-card__desc {
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 0.9rem;
line-height: 1.6;
margin: 0;
max-width: 760px;
}
.detail-section {
margin-top: 28px;
}
.detail-section__title {
font-size: 1.25rem;
font-weight: 700;
margin: 0 0 12px;
}
.handler-row__desc {
align-items: baseline;
display: flex;
flex-wrap: wrap;
gap: 8px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.handler-row__timing {
color: rgba(var(--v-theme-on-surface), 0.54);
flex-shrink: 0;
font-size: 0.85rem;
font-weight: 600;
}
.handler-groups {
display: flex;
flex-direction: column;
gap: 16px;
}
.handler-group__title {
align-items: center;
display: flex;
font-size: 0.95rem;
font-weight: 700;
gap: 8px;
margin-bottom: 8px;
}
.handler-group__count {
color: rgba(var(--v-theme-on-surface), 0.48);
font-size: 0.85rem;
font-weight: 600;
}
.handler-card,
.handler-card :deep(.v-table),
.handler-card :deep(tbody),
.handler-card :deep(tr),
.handler-card :deep(td) {
background: rgb(var(--v-theme-surface));
}
.detail-info-table__label {
color: rgba(var(--v-theme-on-surface), 0.62);
font-weight: 600;
width: 200px;
}
.detail-handler-table :deep(table) {
table-layout: fixed;
}
.detail-handler-table__name {
width: 220px;
}
.detail-handler-table__name div {
color: rgba(var(--v-theme-on-surface), 0.72);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-cell {
align-items: center;
display: flex;
min-width: 0;
}
.command-cell__toggle {
flex-shrink: 0;
margin-right: 4px;
}
.command-cell__indent {
flex-shrink: 0;
margin-left: 28px;
}
.command-cell code {
background-color: rgba(var(--v-theme-primary), 0.1);
border-radius: 4px;
font-size: 0.9em;
overflow: hidden;
padding: 2px 6px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-cell code.command-code--group {
background-color: rgba(var(--v-theme-info), 0.12);
}
.command-cell code.command-code--sub {
background-color: rgba(var(--v-theme-secondary), 0.1);
color: rgb(var(--v-theme-secondary));
}
.detail-link {
align-items: center;
color: rgb(var(--v-theme-primary));
display: inline-flex;
gap: 6px;
max-width: 100%;
}
.detail-link span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.docs-card {
background: rgb(var(--v-theme-surface));
color: rgba(var(--v-theme-on-surface), 0.9);
}
.docs-state {
align-items: center;
display: flex;
justify-content: center;
min-height: 160px;
}
.docs-markdown {
color: rgba(var(--v-theme-on-surface), 0.9);
font-size: 0.95rem;
line-height: 1.65;
}
.docs-markdown :deep(h1),
.docs-markdown :deep(h2),
.docs-markdown :deep(h3),
.docs-markdown :deep(h4),
.docs-markdown :deep(h5),
.docs-markdown :deep(h6) {
color: rgba(var(--v-theme-on-surface), 0.9);
font-weight: 700;
line-height: 1.3;
margin: 1.4em 0 0.6em;
}
.docs-markdown :deep(h1:first-child),
.docs-markdown :deep(h2:first-child),
.docs-markdown :deep(h3:first-child) {
margin-top: 0;
}
.docs-markdown :deep(p),
.docs-markdown :deep(ul),
.docs-markdown :deep(ol),
.docs-markdown :deep(blockquote),
.docs-markdown :deep(pre),
.docs-markdown :deep(table) {
margin-bottom: 1em;
}
.docs-markdown :deep(ul),
.docs-markdown :deep(ol) {
list-style-position: inside;
padding-left: 0;
}
.docs-markdown :deep(li) {
margin: 0.25em 0;
}
.docs-markdown :deep(a) {
color: rgb(var(--v-theme-primary));
text-decoration: none;
}
.docs-markdown :deep(a:hover) {
text-decoration: underline;
}
.docs-markdown :deep(pre) {
background: rgba(var(--v-theme-on-surface), 0.06);
border-radius: 8px;
overflow-x: auto;
padding: 12px;
}
.docs-markdown :deep(code) {
background: rgba(var(--v-theme-on-surface), 0.06);
border-radius: 4px;
font-size: 0.9em;
padding: 0.15em 0.35em;
}
.docs-markdown :deep(pre code) {
background: transparent;
padding: 0;
}
.docs-markdown :deep(blockquote) {
border-left: 4px solid rgba(var(--v-border-color), var(--v-border-opacity));
color: rgba(var(--v-theme-on-surface), 0.62);
padding-left: 12px;
}
.docs-markdown :deep(img) {
max-width: 100%;
}
.docs-markdown :deep(table) {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.docs-markdown :deep(th),
.docs-markdown :deep(td) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
padding: 6px 10px;
}
.docs-markdown :deep(th) {
background: rgba(var(--v-theme-on-surface), 0.06);
}
@media (max-width: 700px) {
.plugin-detail-page {
padding-left: 16px;
padding-right: 16px;
}
.detail-info-table__label {
width: 120px;
}
.detail-handler-table__name {
width: 140px;
}
}
</style>

View File

@@ -1,11 +1,5 @@
export const SHOW_RESERVED_PLUGINS_STORAGE_KEY = "showReservedPlugins";
export const PLUGIN_LIST_VIEW_MODE_STORAGE_KEY = "pluginListViewMode";
export const PIN_UPDATES_ON_TOP_STORAGE_KEY = "pinUpdatesOnTop";
export const PINNED_EXTENSIONS_STORAGE_KEY = "astrbot.pinnedExtensions";
/**
* Resolve the storage backend for reading preferences.
* Pass `null` to explicitly disable storage access in callers/tests.
*/
const getStorageForRead = (storageOverride) => {
if (storageOverride === null) {
return null;
@@ -26,10 +20,6 @@ const getStorageForRead = (storageOverride) => {
}
};
/**
* Resolve the storage backend for writing preferences.
* Pass `null` to explicitly disable storage access in callers/tests.
*/
const getStorageForWrite = (storageOverride) => {
if (storageOverride === null) {
return null;
@@ -50,34 +40,49 @@ const getStorageForWrite = (storageOverride) => {
}
};
export const readBooleanPreference = (key, fallback, storage) => {
const normalizePinnedExtensions = (value) => {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
return value
.filter((item) => typeof item === "string" && item.trim().length > 0)
.map((item) => item.trim())
.filter((item) => {
if (seen.has(item)) {
return false;
}
seen.add(item);
return true;
});
};
export const readPinnedExtensions = (storage) => {
const targetStorage = getStorageForRead(storage);
if (!targetStorage) {
return fallback;
return [];
}
try {
const saved = targetStorage.getItem(key);
if (saved === "true") {
return true;
}
if (saved === "false") {
return false;
}
return fallback;
const raw = targetStorage.getItem(PINNED_EXTENSIONS_STORAGE_KEY);
return normalizePinnedExtensions(raw ? JSON.parse(raw) : []);
} catch {
return fallback;
return [];
}
};
export const writeBooleanPreference = (key, value, storage) => {
export const writePinnedExtensions = (names, storage) => {
const targetStorage = getStorageForWrite(storage);
if (!targetStorage) {
return;
}
try {
targetStorage.setItem(key, String(value));
targetStorage.setItem(
PINNED_EXTENSIONS_STORAGE_KEY,
JSON.stringify(normalizePinnedExtensions(names)),
);
} catch {
// Ignore restricted storage environments.
}

View File

@@ -14,16 +14,8 @@ import {
getValidHashTab,
replaceTabRoute,
} from "@/utils/hashRouteTabs.mjs";
import {
PIN_UPDATES_ON_TOP_STORAGE_KEY,
PLUGIN_LIST_VIEW_MODE_STORAGE_KEY,
SHOW_RESERVED_PLUGINS_STORAGE_KEY,
readBooleanPreference,
writeBooleanPreference,
} from "./extensionPreferenceStorage.mjs";
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
const useRandomPluginsDisplay = ({ activeTab, marketSearch, currentPage }) => {
const showRandomPlugins = ref(true);
@@ -78,7 +70,6 @@ export const useExtensionPage = () => {
const { tm } = useModuleI18n("features/extension");
const router = useRouter();
const route = useRoute();
const { width } = useDisplay();
const getSelectedGitHubProxy = () => {
if (typeof window === "undefined" || !window.localStorage) return "";
@@ -129,11 +120,6 @@ export const useExtensionPage = () => {
message: "",
});
// 从 localStorage 恢复显示系统插件的状态,默认为 false隐藏
const getInitialShowReserved = () => {
return readBooleanPreference(SHOW_RESERVED_PLUGINS_STORAGE_KEY, false);
};
const showReserved = ref(getInitialShowReserved());
const snack_message = ref("");
const snack_show = ref(false);
const snack_success = ref("success");
@@ -178,23 +164,7 @@ export const useExtensionPage = () => {
repoUrl: null,
});
// 新增变量支持列表视图
// 从 localStorage 恢复显示模式,默认为 false卡片视图
const getInitialListViewMode = () => {
return readBooleanPreference(PLUGIN_LIST_VIEW_MODE_STORAGE_KEY, false);
};
const isListView = ref(getInitialListViewMode());
const pluginSearch = ref("");
const installedStatusFilter = ref("all");
const installedSortBy = ref("default");
const installedSortOrder = ref("desc");
const getInitialPinUpdatesOnTop = () => {
return readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true);
};
const pinUpdatesOnTop = ref(getInitialPinUpdatesOnTop());
watch(pinUpdatesOnTop, (val) => {
writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, val);
});
const loading_ = ref(false);
// 分页相关
@@ -348,67 +318,9 @@ export const useExtensionPage = () => {
return items;
});
const installedSortItems = computed(() => [
{ title: tm("sort.default"), value: "default" },
{ title: tm("sort.installTime"), value: "install_time" },
{ title: tm("sort.name"), value: "name" },
{ title: tm("sort.author"), value: "author" },
{ title: tm("sort.updateStatus"), value: "update_status" },
]);
const installedSortUsesOrder = computed(
() => installedSortBy.value !== "default",
);
// 插件表格的表头定义
const showAuthorColumn = computed(() => width.value >= 1280);
const pluginHeaders = computed(() => {
const headers = [
{
title: tm("table.headers.name"),
key: "name",
sortable: false,
width: showAuthorColumn.value ? "24%" : "26%",
},
{
title: tm("table.headers.description"),
key: "desc",
sortable: false,
width: showAuthorColumn.value ? "32%" : "36%",
},
{
title: tm("table.headers.version"),
key: "version",
sortable: false,
width: showAuthorColumn.value ? "12%" : "14%",
},
];
if (showAuthorColumn.value) {
headers.push({
title: tm("table.headers.author"),
key: "author",
sortable: false,
width: "10%",
});
}
headers.push({
title: tm("table.headers.actions"),
key: "actions",
sortable: false,
width: showAuthorColumn.value ? "22%" : "24%",
});
return headers;
});
// 过滤要显示的插件
const filteredExtensions = computed(() => {
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
if (!showReserved.value) {
return data.filter((ext) => !ext.reserved);
}
return data;
});
@@ -421,126 +333,35 @@ export const useExtensionPage = () => {
},
);
const compareInstalledPluginAuthors = (left, right) =>
normalizeStr(left?.author ?? "").localeCompare(
normalizeStr(right?.author ?? ""),
undefined,
{ sensitivity: "base" },
);
const getInstalledAtTimestamp = (plugin) => {
const parsed = Date.parse(plugin?.installed_at ?? "");
return Number.isFinite(parsed) ? parsed : null;
};
const compareInstalledFallback = (left, right) => {
const reservedDiff =
Number(!!left.plugin?.reserved) - Number(!!right.plugin?.reserved);
if (reservedDiff !== 0) {
return reservedDiff;
}
const nameCompare = compareInstalledPluginNames(left.plugin, right.plugin);
return nameCompare !== 0 ? nameCompare : left.index - right.index;
};
const compareInstalledUpdatePinning = (left, right) => {
const leftHasUpdate = left.plugin?.has_update ? 1 : 0;
const rightHasUpdate = right.plugin?.has_update ? 1 : 0;
return rightHasUpdate - leftHasUpdate;
};
const sortInstalledPlugins = (plugins) => {
return plugins
.map((plugin, index) => ({
plugin,
index,
installedAtTimestamp: getInstalledAtTimestamp(plugin),
}))
.sort((left, right) => {
if (
pinUpdatesOnTop.value &&
installedSortBy.value !== "update_status"
) {
// Pinning updates is a primary grouping; the selected sort order still
// applies within the "has update" and "no update" groups below.
const pinCompare = compareInstalledUpdatePinning(left, right);
if (pinCompare !== 0) {
return pinCompare;
}
}
if (installedSortBy.value === "install_time") {
const leftTimestamp = left.installedAtTimestamp;
const rightTimestamp = right.installedAtTimestamp;
if (leftTimestamp == null && rightTimestamp == null) {
return compareInstalledFallback(left, right);
}
if (leftTimestamp == null) {
return 1;
}
if (rightTimestamp == null) {
return -1;
}
const timeDiff =
installedSortOrder.value === "desc"
? rightTimestamp - leftTimestamp
: leftTimestamp - rightTimestamp;
return timeDiff !== 0
? timeDiff
: compareInstalledFallback(left, right);
}
if (installedSortBy.value === "name") {
const nameCompare = compareInstalledPluginNames(left.plugin, right.plugin);
if (nameCompare !== 0) {
return installedSortOrder.value === "desc"
? -nameCompare
: nameCompare;
}
return compareInstalledFallback(left, right);
}
if (installedSortBy.value === "author") {
const authorCompare = compareInstalledPluginAuthors(
left.plugin,
right.plugin,
);
if (authorCompare !== 0) {
return installedSortOrder.value === "desc"
? -authorCompare
: authorCompare;
}
return compareInstalledFallback(left, right);
}
if (installedSortBy.value === "update_status") {
const updateDiff =
installedSortOrder.value === "desc"
? compareInstalledUpdatePinning(left, right)
: compareInstalledUpdatePinning(right, left);
return updateDiff !== 0
? updateDiff
: compareInstalledFallback(left, right);
}
return compareInstalledFallback(left, right);
})
.sort(compareInstalledFallback)
.map((item) => item.plugin);
};
// 通过搜索过滤插件
const filteredPlugins = computed(() => {
const plugins = filteredExtensions.value.filter((plugin) => {
if (installedStatusFilter.value === "enabled") {
return !!plugin.activated;
}
if (installedStatusFilter.value === "disabled") {
return !plugin.activated;
}
return true;
});
const query = buildSearchQuery(pluginSearch.value);
const filtered = query
? plugins.filter((plugin) => matchesPluginSearch(plugin, query))
: plugins;
? filteredExtensions.value.filter((plugin) =>
matchesPluginSearch(plugin, query),
)
: filteredExtensions.value;
return sortInstalledPlugins(filtered);
});
@@ -658,12 +479,6 @@ export const useExtensionPage = () => {
});
// 方法
const toggleShowReserved = () => {
showReserved.value = !showReserved.value;
// 保存到 localStorage
writeBooleanPreference(SHOW_RESERVED_PLUGINS_STORAGE_KEY, showReserved.value);
};
const toast = (message, success) => {
snack_message.value = message;
snack_show.value = true;
@@ -922,7 +737,10 @@ export const useExtensionPage = () => {
// 确认强制更新
// 显示更新全部插件确认对话框
const showUpdateAllConfirm = () => {
if (updatableExtensions.value.length === 0) return;
if (updatableExtensions.value.length === 0) {
toast(tm("messages.noUpdatesAvailable"), "info");
return;
}
updateAllConfirmDialog.show = true;
};
@@ -945,7 +763,11 @@ export const useExtensionPage = () => {
};
const updateAllExtensions = async () => {
if (updatingAll.value || updatableExtensions.value.length === 0) return;
if (updatingAll.value) return;
if (updatableExtensions.value.length === 0) {
toast(tm("messages.noUpdatesAvailable"), "info");
return;
}
updatingAll.value = true;
loadingDialog.title = tm("status.loading");
loadingDialog.statusCode = 0;
@@ -1624,11 +1446,6 @@ export const useExtensionPage = () => {
}, 300); // 300ms 防抖延迟
});
// 监听显示模式变化并保存到 localStorage
watch(isListView, (newVal) => {
writeBooleanPreference(PLUGIN_LIST_VIEW_MODE_STORAGE_KEY, newVal);
});
watch(
[() => dialog.value, () => extension_url.value, () => uploadTab.value],
async ([dialogOpen, _, currentUploadTab]) => {
@@ -1693,8 +1510,6 @@ export const useExtensionPage = () => {
extractTabFromHash,
syncTabFromHash,
extension_data,
getInitialShowReserved,
showReserved,
snack_message,
snack_show,
snack_success,
@@ -1710,13 +1525,7 @@ export const useExtensionPage = () => {
forceUpdateDialog,
updateAllConfirmDialog,
changelogDialog,
getInitialListViewMode,
isListView,
pluginSearch,
installedStatusFilter,
installedSortBy,
installedSortOrder,
pinUpdatesOnTop,
loading_,
currentPage,
marketCategoryFilter,
@@ -1755,9 +1564,6 @@ export const useExtensionPage = () => {
toPinyinText,
toInitials,
plugin_handler_info_headers,
installedSortItems,
installedSortUsesOrder,
pluginHeaders,
filteredExtensions,
filteredPlugins,
filteredMarketPlugins,
@@ -1772,7 +1578,6 @@ export const useExtensionPage = () => {
totalPages,
paginatedPlugins,
updatableExtensions,
toggleShowReserved,
toast,
resetLoadingDialog,
onLoadingDialogResult,

View File

@@ -21,9 +21,8 @@
<!-- 主内容 -->
<div v-else class="document-content">
<!-- 文档信息卡片 -->
<v-card elevation="2" class="mb-6">
<v-card variant="outlined" class="mb-6">
<v-card-title>{{ t('info.title') }}</v-card-title>
<v-divider />
<v-card-text>
<v-row>
<v-col cols="12" md="3">
@@ -78,7 +77,7 @@
</v-card>
<!-- 分块列表 -->
<v-card elevation="2">
<v-card variant="outlined">
<v-card-title class="d-flex align-center pa-4">
<span>{{ t('chunks.title') }}</span>
<v-chip class="ml-2" size="small" variant="tonal">
@@ -97,8 +96,6 @@
/> -->
</v-card-title>
<v-divider />
<v-card-text class="pa-0">
<v-data-table
:headers="headers"
@@ -187,7 +184,6 @@
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="showViewDialog = false" />
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<v-list density="comfortable">
<v-list-item>
@@ -215,14 +211,11 @@
</v-list-item>
</v-list>
<v-divider class="my-4" />
<div class="text-caption text-medium-emphasis mb-2">{{ t('view.content') }}</div>
<div class="chunk-content-view">
{{ selectedChunk?.content }}
</div>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="showViewDialog = false">
@@ -434,6 +427,10 @@ onMounted(() => {
animation: fadeIn 0.3s ease;
}
.document-detail-page :deep(.v-card--variant-outlined) {
background: rgb(var(--v-theme-surface));
}
@keyframes fadeIn {
from {
opacity: 0;

View File

@@ -1,23 +1,5 @@
<template>
<div class="kb-detail-page">
<!-- 页面头部 -->
<div class="page-header">
<v-btn
icon="mdi-arrow-left"
variant="text"
@click="$router.push({ name: 'NativeKBList' })"
/>
<div class="header-content">
<div class="kb-title">
<span class="kb-emoji">{{ kb.emoji || '📚' }}</span>
<h1 class="text-h4">{{ kb.kb_name }}</h1>
</div>
<p v-if="kb.description" class="text-subtitle-1 text-medium-emphasis mt-2">
{{ kb.description }}
</p>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<v-progress-circular indeterminate color="primary" size="64" />
@@ -52,9 +34,8 @@
<v-window-item value="overview">
<v-row>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card variant="outlined">
<v-card-title>{{ t('overview.title') }}</v-card-title>
<v-divider />
<v-card-text>
<v-list density="comfortable">
<v-list-item>
@@ -102,9 +83,8 @@
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2" class="mb-4">
<v-card variant="outlined" class="mb-4">
<v-card-title>{{ t('overview.stats') }}</v-card-title>
<v-divider />
<v-card-text>
<v-row>
<v-col cols="6">
@@ -125,9 +105,8 @@
</v-card-text>
</v-card>
<v-card elevation="2">
<v-card variant="outlined">
<v-card-title>{{ t('overview.embeddingModel') }}</v-card-title>
<v-divider />
<v-card-text>
<v-list density="comfortable">
<v-list-item>
@@ -177,7 +156,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
@@ -188,6 +167,10 @@ import SettingsTab from './components/SettingsTab.vue'
const { tm: t } = useModuleI18n('features/knowledge-base/detail')
const route = useRoute()
const emit = defineEmits<{
(event: 'title-change', title: string): void
}>()
const kbId = ref(route.params.kbId as string)
const loading = ref(true)
const activeTab = ref('overview')
@@ -214,6 +197,7 @@ const loadKB = async () => {
})
if (response.data.status === 'ok') {
kb.value = response.data.data
emit('title-change', kb.value.kb_name || '')
} else {
showSnackbar(response.data.message || '加载失败', 'error')
}
@@ -241,51 +225,22 @@ const formatDate = (dateStr: string) => {
onMounted(() => {
loadKB()
})
watch(
() => kb.value?.kb_name,
(name) => {
emit('title-change', name || '')
},
)
</script>
<style scoped>
.kb-detail-page {
max-width: 1400px;
margin: 0 auto;
animation: fadeIn 0.3s ease;
width: 100%;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.page-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 32px;
}
.header-content {
flex: 1;
}
.kb-title {
display: flex;
align-items: center;
gap: 16px;
}
.kb-emoji {
font-size: 48px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
.kb-detail-page :deep(.v-card--variant-outlined) {
background: rgb(var(--v-theme-surface));
}
.loading-container {
@@ -296,21 +251,6 @@ onMounted(() => {
min-height: 400px;
}
.kb-content {
animation: slideUp 0.4s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stat-box {
display: flex;
flex-direction: column;
@@ -340,12 +280,7 @@ onMounted(() => {
/* 响应式设计 */
@media (max-width: 768px) {
.kb-title {
flex-direction: column;
align-items: flex-start;
}
.kb-emoji {
font-size: 36px;
font-size: 1.25rem;
}
}
</style>

View File

@@ -1,69 +1,84 @@
<template>
<div class="kb-list-page">
<!-- 页面标题 -->
<div class="page-header">
<div>
<h1 class="text-h4 mb-2">{{ t('list.title') }}</h1>
<p class="text-subtitle-1 text-medium-emphasis">{{ t('list.subtitle') }}</p>
</div>
<v-btn icon="mdi-information-outline" variant="text" size="small" color="grey"
href="https://docs.astrbot.app/use/knowledge-base.html" target="_blank" />
</div>
<!-- 操作按钮栏 -->
<div class="action-bar mb-6">
<v-btn prepend-icon="mdi-plus" color="primary" variant="elevated" @click="showCreateDialog = true">
{{ t('list.create') }}
</v-btn>
<v-btn prepend-icon="mdi-refresh" variant="tonal" @click="loadKnowledgeBases" :loading="loading">
{{ t('list.refresh') }}
</v-btn>
</div>
<!-- 知识库网格 -->
<div v-if="loading && kbList.length === 0" class="loading-container">
<v-progress-circular indeterminate color="primary" size="64" />
<p class="mt-4 text-medium-emphasis">{{ t('list.loading') }}</p>
</div>
<div v-else-if="kbList.length > 0" class="kb-grid">
<v-card v-for="kb in kbList" :key="kb.kb_id" class="kb-card" elevation="2" :hover="!kb.init_error"
:class="{ 'kb-card-error': kb.init_error }"
@click="!kb.init_error && navigateToDetail(kb.kb_id)">
<!-- Error badge -->
<v-badge v-if="kb.init_error" color="error" icon="mdi-alert-circle"
class="kb-error-badge position-absolute" style="top: 0; right: 0; transform: translate(34%, -34%);" />
<div class="kb-card-content" :class="{ 'kb-card-content-error': kb.init_error }">
<div class="kb-emoji">{{ kb.emoji || '📚' }}</div>
<h3 class="kb-name">{{ kb.kb_name }}</h3>
<p v-if="!kb.init_error" class="kb-description text-medium-emphasis">{{ kb.description || '暂无描述' }}</p>
<div v-else-if="kbList.length > 0" class="kb-list">
<OutlinedActionListItem
v-for="kb in kbList"
:key="kb.kb_id"
:title="kb.kb_name"
:clickable="!kb.init_error"
@click="navigateToDetail(kb.kb_id)"
>
<template #title-prepend>
<span class="kb-list-emoji">{{ kb.emoji || '📚' }}</span>
</template>
<!-- Error message display -->
<div v-if="kb.init_error" class="kb-error-panel mt-3 mb-2">
<template #title-extra>
<v-chip
v-if="kb.init_error"
color="error"
size="x-small"
variant="tonal"
>
{{ t('list.initError') }}
</v-chip>
</template>
<div v-if="!kb.init_error" class="kb-description text-body-2 text-medium-emphasis">
{{ kb.description || '暂无描述' }}
</div>
<div v-if="kb.init_error" class="kb-error-panel">
<div class="kb-error-title">
<v-icon size="16" color="error">mdi-close-circle</v-icon>
<span>{{ t('list.initError') }}</span>
</div>
<div class="kb-error-detail" :title="kb.init_error">{{ kb.init_error }}</div>
</div>
</div>
<div class="kb-stats mt-4" v-if="!kb.init_error">
<div class="kb-stats" v-if="!kb.init_error">
<div class="stat-item">
<v-icon size="small" color="primary">mdi-file-document</v-icon>
<v-icon size="small">mdi-file-document</v-icon>
<span>{{ kb.doc_count || 0 }} {{ t('list.documents') }}</span>
</div>
<div class="stat-item">
<v-icon size="small" color="secondary">mdi-text-box</v-icon>
<v-icon size="small">mdi-text-box</v-icon>
<span>{{ kb.chunk_count || 0 }} {{ t('list.chunks') }}</span>
</div>
</div>
<div class="kb-actions" :class="{ 'error-actions': kb.init_error }">
<v-btn v-if="!kb.init_error" icon="mdi-pencil" size="small" variant="text" color="info" @click.stop="editKB(kb)" />
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="confirmDelete(kb)" />
</div>
</div>
</v-card>
<template #actions>
<v-tooltip v-if="!kb.init_error" :text="t('card.edit')" location="top">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-pencil-outline"
variant="text"
size="small"
class="list-action-icon-btn"
@click.stop="editKB(kb)"
/>
</template>
</v-tooltip>
<v-tooltip :text="t('card.delete')" location="top">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-delete-outline"
variant="text"
size="small"
class="list-action-icon-btn"
@click.stop="confirmDelete(kb)"
/>
</template>
</v-tooltip>
</template>
</OutlinedActionListItem>
</div>
<!-- 空状态 -->
@@ -76,6 +91,36 @@
</v-btn>
</div>
<div class="kb-fab-stack">
<v-tooltip :text="t('list.refresh')" location="left">
<template #activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-refresh"
size="x-large"
variant="elevated"
class="kb-fab"
:loading="loading"
@click="loadKnowledgeBases()"
/>
</template>
</v-tooltip>
<v-tooltip :text="t('list.create')" location="left">
<template #activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-plus"
size="x-large"
variant="elevated"
class="kb-fab"
@click="showCreateDialog = true"
/>
</template>
</v-tooltip>
</div>
<!-- 创建/编辑对话框 -->
<v-dialog v-model="showCreateDialog" max-width="600px" persistent>
<v-card>
@@ -214,6 +259,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
import OutlinedActionListItem from '@/components/shared/OutlinedActionListItem.vue'
const { tm: t } = useModuleI18n('features/knowledge-base/index')
const router = useRouter()
@@ -454,114 +500,31 @@ onMounted(() => {
<style scoped>
.kb-list-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
}
.action-bar {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* 知识库网格 */
.kb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.kb-card {
position: relative;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.kb-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15) !important;
}
/* Error state card styles */
.kb-card-error {
cursor: not-allowed;
border: 1px solid rgba(var(--v-theme-error), 0.3);
background-color: rgba(var(--v-theme-error), 0.02) !important;
overflow: visible; /* Allow badge to overflow */
}
.kb-card-error:hover {
transform: none;
box-shadow: 0 4px 12px rgba(var(--v-theme-error), 0.1) !important;
border-color: rgba(var(--v-theme-error), 0.5);
}
.kb-card-error .kb-emoji {
opacity: 0.7;
filter: grayscale(0.5);
}
.kb-card-error .kb-name {
color: rgba(var(--v-theme-on-surface), 0.7);
}
.kb-error-badge {
z-index: 10;
opacity: 0.9;
}
.kb-card-content {
padding: 24px;
.kb-list {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-height: 260px;
position: relative;
gap: 12px;
}
.kb-card-content-error {
justify-content: center;
gap: 8px;
}
.kb-emoji {
font-size: 56px;
margin-bottom: 8px;
}
.kb-name {
.kb-list-emoji {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 8px;
color: rgb(var(--v-theme-on-surface));
line-height: 1;
}
.kb-description {
font-size: 0.875rem;
line-height: 1.5;
max-height: 3em;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
.kb-stats {
display: flex;
gap: 16px;
width: 100%;
justify-content: center;
margin-top: 6px;
}
.kb-error-panel {
@@ -599,22 +562,38 @@ onMounted(() => {
align-items: center;
gap: 6px;
font-size: 0.875rem;
color: rgba(var(--v-theme-on-surface), 0.62);
}
.list-action-icon-btn {
color: rgba(var(--v-theme-on-surface), 0.78);
}
.list-action-icon-btn:hover {
background: rgba(var(--v-theme-on-surface), 0.08);
color: rgb(var(--v-theme-on-surface));
font-weight: 500;
}
.kb-actions {
position: absolute;
bottom: 16px;
right: 16px;
.kb-fab-stack {
align-items: center;
bottom: 52px;
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.2s ease;
flex-direction: column;
gap: 16px;
position: fixed;
right: 52px;
z-index: 10000;
}
.kb-card:hover .kb-actions {
opacity: 1;
.kb-fab {
border-radius: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.kb-fab:hover {
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
transform: translateY(-4px) scale(1.05);
}
/* 空状态 */
@@ -676,14 +655,6 @@ onMounted(() => {
/* 响应式设计 */
@media (max-width: 768px) {
.kb-list-page {
padding: 16px;
}
.kb-grid {
grid-template-columns: 1fr;
}
.emoji-grid {
grid-template-columns: repeat(6, 1fr);
}

View File

@@ -2,7 +2,7 @@
<div class="documents-tab">
<!-- 操作栏 -->
<div class="action-bar mb-4">
<v-btn prepend-icon="mdi-upload" color="primary" variant="elevated" @click="showUploadDialog = true">
<v-btn prepend-icon="mdi-upload" color="primary" variant="outlined" @click="showUploadDialog = true">
{{ t('documents.upload') }}
</v-btn>
<v-text-field v-model="searchQuery" prepend-inner-icon="mdi-magnify" :placeholder="'搜索文档...'" variant="outlined"
@@ -10,7 +10,7 @@
</div>
<!-- 文档列表 -->
<v-card elevation="2">
<v-card variant="outlined">
<v-data-table :headers="headers" :items="documents" :loading="loading" :search="searchQuery" :items-per-page="10">
<template #item.doc_name="{ item }">
<div class="d-flex align-center gap-2">
@@ -65,8 +65,6 @@
<v-btn icon="mdi-close" variant="text" @click="closeUploadDialog" />
</v-card-title>
<v-divider />
<v-tabs v-model="uploadMode" grow class="mb-4">
<v-tab value="file">{{ t('upload.fileUpload') }}</v-tab>
<v-tab value="url">
@@ -193,8 +191,6 @@
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="closeUploadDialog" :disabled="uploading">
@@ -212,14 +208,12 @@
<v-dialog v-model="showDeleteDialog" max-width="450px">
<v-card>
<v-card-title class="pa-4 text-h6">{{ t('documents.delete') }}</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<p>{{ t('documents.deleteConfirm', { name: deleteTarget?.doc_name || '' }) }}</p>
<v-alert type="error" variant="tonal" density="compact" class="mt-4">
{{ t('documents.deleteWarning') }}
</v-alert>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">取消</v-btn>

View File

@@ -1,12 +1,11 @@
<template>
<div class="retrieval-tab">
<v-card elevation="2">
<v-card variant="outlined">
<v-card-title class="pa-4 pb-0">{{ t('retrieval.title') }}</v-card-title>
<v-card-subtitle class="pb-4 pt-2">
{{ t('retrieval.subtitle') }}
</v-card-subtitle>
<v-divider />
<v-progress-linear v-if="loading" indeterminate color="primary" height="2" />
<v-card-text class="pa-6">
@@ -58,8 +57,6 @@
<!-- 检索结果 -->
<div v-if="hasSearched" class="results-section">
<v-divider class="mb-4" />
<div class="d-flex align-center mb-4">
<h3 class="text-h6">{{ t('retrieval.results') }}</h3>
<v-chip class="ml-3" color="primary" variant="tonal" size="small">
@@ -93,8 +90,6 @@
</v-chip>
</v-card-title>
<v-divider />
<v-card-text class="pa-4">
<div class="content-box">
{{ result.content }}

View File

@@ -1,8 +1,7 @@
<template>
<div class="settings-tab">
<v-card elevation="2">
<v-card variant="outlined">
<v-card-title class="pa-4">{{ t('settings.title') }}</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<v-form ref="formRef">
@@ -104,8 +103,6 @@
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn

View File

@@ -1,37 +1,111 @@
<template>
<div class="kb-container">
<router-view v-slot="{ Component }">
<transition name="kb-fade" mode="out-in">
<component :is="Component" :key="$route.fullPath" />
</transition>
</router-view>
<div class="page-header">
<div>
<h1 class="kb-page-title">
<button
v-if="isDetailRoute"
class="kb-page-title__parent"
type="button"
@click="goToList"
>
{{ t('list.title') }}
</button>
<template v-else>
{{ t('list.title') }}
</template>
<template v-if="isDetailRoute">
<v-icon icon="mdi-chevron-right" size="24" class="mx-1" />
<span class="kb-page-title__current">{{ displayDetailTitle }}</span>
</template>
</h1>
<p class="text-body-2 text-medium-emphasis">{{ t('list.subtitle') }}</p>
</div>
<v-btn
icon="mdi-information-outline"
variant="text"
size="small"
color="grey"
href="https://docs.astrbot.app/use/knowledge-base.html"
target="_blank"
/>
</div>
<router-view @title-change="detailTitle = $event" />
</div>
</template>
<script setup lang="ts">
// 主容器组件,提供路由出口和页面切换动画
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useModuleI18n } from '@/i18n/composables'
const { tm: t } = useModuleI18n('features/knowledge-base/index')
const route = useRoute()
const router = useRouter()
const detailTitle = ref('')
const isDetailRoute = computed(() => route.name === 'NativeKBDetail')
const displayDetailTitle = computed(() => detailTitle.value || String(route.params.kbId || ''))
const goToList = () => {
router.push({ name: 'NativeKBList' })
}
</script>
<style scoped>
.kb-container {
margin: 0 auto;
max-width: 1400px;
padding: 24px;
width: 100%;
height: 100%;
position: relative;
}
/* 页面切换动画 */
.kb-fade-enter-active,
.kb-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
.page-header {
align-items: flex-start;
display: flex;
justify-content: space-between;
margin-bottom: 24px;
}
.kb-fade-enter-from {
opacity: 0;
transform: translateY(10px);
.kb-page-title {
align-items: center;
display: flex;
flex-wrap: wrap;
font-size: 1.5rem;
font-weight: 700;
gap: 2px;
letter-spacing: 0;
line-height: 1.2;
margin: 0 0 4px;
min-width: 0;
}
.kb-fade-leave-to {
opacity: 0;
transform: translateY(-10px);
.kb-page-title__parent {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
font: inherit;
padding: 0;
}
.kb-page-title__parent:hover {
color: rgb(var(--v-theme-primary));
}
.kb-page-title__current {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 768px) {
.kb-container {
padding: 16px;
}
}
</style>

View File

@@ -3,7 +3,6 @@
<v-container fluid class="stats-shell pa-4 pa-md-6">
<div class="stats-header">
<div>
<div class="eyebrow">{{ t('header.eyebrow') }}</div>
<h1 class="stats-title">{{ t('header.title') }}</h1>
<p class="stats-subtitle">{{ t('header.subtitle') }}</p>
</div>
@@ -723,30 +722,20 @@ onBeforeUnmount(() => {
margin-bottom: 24px;
}
.eyebrow {
margin-bottom: 8px;
color: var(--stats-subtle);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.stats-title {
margin: 0;
font-size: clamp(34px, 4vw, 46px);
line-height: 1.04;
font-size: 1.5rem;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.04em;
letter-spacing: 0;
}
.stats-subtitle {
margin: 10px 0 0;
margin: 4px 0 0;
color: var(--stats-muted);
font-size: 15px;
font-size: 0.875rem;
}
.stats-page.is-dark .eyebrow,
.stats-page.is-dark .stats-subtitle,
.stats-page.is-dark .metric-label,
.stats-page.is-dark .section-subtitle,

View File

@@ -2,74 +2,53 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
PIN_UPDATES_ON_TOP_STORAGE_KEY,
readBooleanPreference,
writeBooleanPreference,
PINNED_EXTENSIONS_STORAGE_KEY,
readPinnedExtensions,
writePinnedExtensions,
} from '../src/views/extension/extensionPreferenceStorage.mjs';
test("readBooleanPreference returns fallback when storage access throws", () => {
const storage = {
getItem() {
throw new Error("SecurityError");
},
};
assert.equal(
readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, storage),
true,
);
test('readPinnedExtensions uses the legacy pinned extension storage key', () => {
assert.equal(PINNED_EXTENSIONS_STORAGE_KEY, 'astrbot.pinnedExtensions');
});
test("readBooleanPreference parses stored boolean strings", () => {
test('readPinnedExtensions parses stored pinned extension names', () => {
const storage = {
getItem(key) {
return key === PIN_UPDATES_ON_TOP_STORAGE_KEY ? "false" : null;
return key === PINNED_EXTENSIONS_STORAGE_KEY
? JSON.stringify(['alpha', 'beta', 'alpha', '', 1])
: null;
},
};
assert.equal(
readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, storage),
false,
);
assert.deepEqual(readPinnedExtensions(storage), ['alpha', 'beta']);
});
test("readBooleanPreference treats explicit null storage as unavailable", () => {
assert.equal(
readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, null),
true,
);
test('readPinnedExtensions returns an empty array when storage access fails', () => {
const storage = {
getItem() {
throw new Error('SecurityError');
},
};
assert.deepEqual(readPinnedExtensions(storage), []);
});
test("readBooleanPreference treats invalid storage overrides as unavailable", () => {
assert.equal(
readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, {}),
true,
);
});
test("writeBooleanPreference stores boolean strings and swallows storage errors", () => {
test('writePinnedExtensions stores normalized pinned extension names', () => {
const writes = [];
const storage = {
setItem(key, value) {
writes.push([key, value]);
throw new Error("QuotaExceededError");
},
};
assert.doesNotThrow(() =>
writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, storage),
);
assert.deepEqual(writes, [[PIN_UPDATES_ON_TOP_STORAGE_KEY, "true"]]);
writePinnedExtensions(['alpha', 'beta', 'alpha', '', null], storage);
assert.deepEqual(writes, [
[PINNED_EXTENSIONS_STORAGE_KEY, JSON.stringify(['alpha', 'beta'])],
]);
});
test("writeBooleanPreference ignores explicit null storage", () => {
assert.doesNotThrow(() =>
writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, null),
);
});
test("writeBooleanPreference ignores invalid storage overrides", () => {
assert.doesNotThrow(() =>
writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, {}),
);
test('writePinnedExtensions ignores unavailable storage', () => {
assert.doesNotThrow(() => writePinnedExtensions(['alpha'], null));
assert.doesNotThrow(() => writePinnedExtensions(['alpha'], {}));
});

View File

@@ -93,3 +93,181 @@ def test_anthropic_empty_output_raises_empty_model_output_error():
completion_id="msg_empty",
stop_reason="end_turn",
)
def _make_anthropic_provider_for_payload_tests() -> anthropic_source.ProviderAnthropic:
return anthropic_source.ProviderAnthropic(
provider_config={"model": "claude-test"},
provider_settings={},
use_api_key=False,
)
def test_prepare_payload_merges_consecutive_tool_results_into_single_user_message():
provider = _make_anthropic_provider_for_payload_tests()
_, new_messages = provider._prepare_payload(
[
{
"role": "assistant",
"content": [{"type": "text", "text": "Reading files"}],
"tool_calls": [
{
"type": "function",
"id": "call_00",
"function": {
"name": "astrbot_file_read_tool",
"arguments": '{"path": "/tmp/one.txt"}',
},
},
{
"type": "function",
"id": "call_01",
"function": {
"name": "astrbot_file_read_tool",
"arguments": '{"path": "/tmp/two.txt"}',
},
},
],
},
{
"role": "tool",
"tool_call_id": "call_00",
"content": "one",
},
{
"role": "tool",
"tool_call_id": "call_01",
"content": "two",
},
]
)
assert len(new_messages) == 2
assert new_messages[1]["role"] == "user"
assert new_messages[1]["content"] == [
{"type": "tool_result", "tool_use_id": "call_00", "content": "one"},
{"type": "tool_result", "tool_use_id": "call_01", "content": "two"},
]
def test_prepare_payload_keeps_single_tool_result_as_single_user_message():
provider = _make_anthropic_provider_for_payload_tests()
_, new_messages = provider._prepare_payload(
[
{
"role": "assistant",
"content": [{"type": "text", "text": "Reading file"}],
"tool_calls": [
{
"type": "function",
"id": "call_00",
"function": {
"name": "astrbot_file_read_tool",
"arguments": '{"path": "/tmp/one.txt"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_00",
"content": "one",
},
]
)
assert len(new_messages) == 2
assert new_messages[1] == {
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_00", "content": "one"}
],
}
def test_prepare_payload_does_not_merge_non_consecutive_tool_results():
provider = _make_anthropic_provider_for_payload_tests()
_, new_messages = provider._prepare_payload(
[
{
"role": "assistant",
"content": [{"type": "text", "text": "First tool"}],
"tool_calls": [
{
"type": "function",
"id": "call_00",
"function": {
"name": "astrbot_file_read_tool",
"arguments": '{"path": "/tmp/one.txt"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_00",
"content": "one",
},
{
"role": "assistant",
"content": [{"type": "text", "text": "Second tool"}],
"tool_calls": [
{
"type": "function",
"id": "call_01",
"function": {
"name": "astrbot_file_read_tool",
"arguments": '{"path": "/tmp/two.txt"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_01",
"content": "two",
},
]
)
assert new_messages == [
{
"role": "assistant",
"content": [
{"type": "text", "text": "First tool"},
{
"type": "tool_use",
"name": "astrbot_file_read_tool",
"input": {"path": "/tmp/one.txt"},
"id": "call_00",
},
],
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_00", "content": "one"}
],
},
{
"role": "assistant",
"content": [
{"type": "text", "text": "Second tool"},
{
"type": "tool_use",
"name": "astrbot_file_read_tool",
"input": {"path": "/tmp/two.txt"},
"id": "call_01",
},
],
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_01", "content": "two"}
],
},
]

View File

@@ -1212,3 +1212,102 @@ async def test_batch_upload_skills_partial_success(
assert data["data"]["failed"] == [
{"filename": "bad_skill.zip", "error": "install failed"}
]
@pytest.mark.asyncio
async def test_skill_file_browser_and_editor_security(
app: Quart,
authenticated_header: dict,
monkeypatch,
tmp_path,
):
async def _fake_sync_skills_to_active_sandboxes():
return
skills_root = tmp_path / "skills"
skill_dir = skills_root / "demo_skill"
skill_dir.mkdir(parents=True)
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(
"---\ndescription: Demo skill\n---\n# Demo\n",
encoding="utf-8",
)
(skill_dir / "notes.txt").write_text("notes", encoding="utf-8")
(skill_dir / "large.md").write_text("x" * (512 * 1024 + 1), encoding="utf-8")
(skill_dir / "binary.md").write_bytes(b"\xff\xfe\x00")
outside_file = tmp_path / "outside.txt"
outside_file.write_text("outside", encoding="utf-8")
if hasattr(os, "symlink"):
os.symlink(outside_file, skill_dir / "outside-link.txt")
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_skills_path",
lambda: str(skills_root),
)
monkeypatch.setattr(
"astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
_fake_sync_skills_to_active_sandboxes,
)
test_client = app.test_client()
list_response = await test_client.get(
"/api/skills/files?name=demo_skill",
headers=authenticated_header,
)
list_data = await list_response.get_json()
assert list_data["status"] == "ok"
listed_paths = {item["path"] for item in list_data["data"]["entries"]}
assert "SKILL.md" in listed_paths
assert "outside-link.txt" not in listed_paths
read_response = await test_client.get(
"/api/skills/file?name=demo_skill&path=SKILL.md",
headers=authenticated_header,
)
read_data = await read_response.get_json()
assert read_data["status"] == "ok"
assert "# Demo" in read_data["data"]["content"]
update_response = await test_client.post(
"/api/skills/file",
json={
"name": "demo_skill",
"path": "SKILL.md",
"content": "# Updated\n",
},
headers=authenticated_header,
)
update_data = await update_response.get_json()
assert update_data["status"] == "ok"
assert skill_md.read_text(encoding="utf-8") == "# Updated\n"
traversal_response = await test_client.get(
"/api/skills/file?name=demo_skill&path=../outside.txt",
headers=authenticated_header,
)
traversal_data = await traversal_response.get_json()
assert traversal_data["status"] == "error"
symlink_response = await test_client.get(
"/api/skills/file?name=demo_skill&path=outside-link.txt",
headers=authenticated_header,
)
symlink_data = await symlink_response.get_json()
assert symlink_data["status"] == "error"
large_response = await test_client.get(
"/api/skills/file?name=demo_skill&path=large.md",
headers=authenticated_header,
)
large_data = await large_response.get_json()
assert large_data["status"] == "error"
assert large_data["message"] == "File is too large"
binary_response = await test_client.get(
"/api/skills/file?name=demo_skill&path=binary.md",
headers=authenticated_header,
)
binary_data = await binary_response.get_json()
assert binary_data["status"] == "error"
assert binary_data["message"] == "File is not valid UTF-8 text"

View File

@@ -1,3 +1,4 @@
import builtins
from types import SimpleNamespace
import pytest
@@ -5,6 +6,7 @@ from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from PIL import Image as PILImage
import astrbot.core.provider.sources.openai_source as openai_source_module
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.provider.sources.groq_source import ProviderGroq
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
@@ -52,6 +54,66 @@ def _make_groq_provider(overrides: dict | None = None) -> ProviderGroq:
)
def test_create_http_client_uses_openai_httpx_module(monkeypatch):
captured: dict[str, object] = {}
def fake_create_proxy_client(
provider_label: str,
proxy: str | None = None,
headers: dict[str, str] | None = None,
verify=None,
httpx_module=None,
):
captured["httpx_module"] = httpx_module
return object()
monkeypatch.setattr(
openai_source_module,
"create_proxy_client",
fake_create_proxy_client,
)
provider = ProviderOpenAIOfficial.__new__(ProviderOpenAIOfficial)
provider._create_http_client({"proxy": ""})
from openai import _base_client as openai_base_client
assert captured["httpx_module"] is openai_base_client.httpx
def test_create_http_client_falls_back_to_global_httpx_module(monkeypatch):
captured: dict[str, object] = {}
def fake_create_proxy_client(
provider_label: str,
proxy: str | None = None,
headers: dict[str, str] | None = None,
verify=None,
httpx_module=None,
):
captured["httpx_module"] = httpx_module
return object()
real_import = builtins.__import__
def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
if name == "openai" and fromlist:
raise ImportError("missing openai._base_client")
return real_import(name, globals, locals, fromlist, level)
monkeypatch.setattr(
openai_source_module,
"create_proxy_client",
fake_create_proxy_client,
)
monkeypatch.setattr(builtins, "__import__", fake_import)
provider = ProviderOpenAIOfficial.__new__(ProviderOpenAIOfficial)
provider._create_http_client({"proxy": ""})
assert captured["httpx_module"] is openai_source_module.httpx
@pytest.mark.asyncio
async def test_handle_api_error_content_moderated_removes_images():
provider = _make_provider(

View File

@@ -0,0 +1,344 @@
"""Tests for ShipyardNeoBooter — readiness gate, shutdown cleanup, and rebuild recovery."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
# ═══════════════════════════════════════════════════════════════
# _wait_until_ready
# ═══════════════════════════════════════════════════════════════
def _make_sandbox_mock(statuses: list[str], *, delete_side_effect=None):
"""Build a sandbox mock that returns *statuses* in order on refresh().
After the list is exhausted subsequent refresh() calls return the last status.
"""
call_count = 0
async def _refresh():
nonlocal call_count
idx = min(call_count, len(statuses) - 1)
call_count += 1
s = statuses[idx]
sandbox.status = SimpleNamespace(value=s)
sandbox = SimpleNamespace(
id="sandbox-test-1",
profile="python-default",
status=SimpleNamespace(value=statuses[0]),
refresh=_refresh,
delete=AsyncMock(side_effect=delete_side_effect),
)
return sandbox
class TestWaitUntilReady:
def _make_booter(self):
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
return ShipyardNeoBooter(
endpoint_url="http://localhost:8114",
access_token="sk-bay-test",
)
@pytest.mark.asyncio
async def test_already_ready_returns_immediately(self):
"""Sandbox is READY on first poll → instant return (warm hit)."""
booter = self._make_booter()
sandbox = _make_sandbox_mock(["ready"])
await booter._wait_until_ready(sandbox)
sandbox.delete.assert_not_called()
@pytest.mark.asyncio
async def test_starting_then_ready(self):
"""Sandbox transitions STARTING → READY within timeout."""
booter = self._make_booter()
sandbox = _make_sandbox_mock(["starting", "starting", "ready"])
await booter._wait_until_ready(sandbox)
sandbox.delete.assert_not_called()
@pytest.mark.asyncio
async def test_failed_deletes_and_raises(self):
"""Sandbox reaches FAILED → delete called → RuntimeError raised."""
booter = self._make_booter()
sandbox = _make_sandbox_mock(["starting", "failed"])
with pytest.raises(RuntimeError, match="terminal state"):
await booter._wait_until_ready(sandbox)
sandbox.delete.assert_awaited_once()
@pytest.mark.asyncio
async def test_expired_deletes_and_raises(self):
"""Sandbox reaches EXPIRED → delete called → RuntimeError raised."""
booter = self._make_booter()
sandbox = _make_sandbox_mock(["starting", "expired"])
with pytest.raises(RuntimeError, match="terminal state"):
await booter._wait_until_ready(sandbox)
sandbox.delete.assert_awaited_once()
@pytest.mark.asyncio
async def test_timeout_deletes_and_raises(self):
"""Sandbox never reaches READY → delete called → TimeoutError raised."""
booter = self._make_booter()
# Return 'idle' every time to simulate a stuck sandbox
sandbox = _make_sandbox_mock(["idle"])
# Override the deadline so we don't actually sleep 180s
original_time = asyncio.get_running_loop().time
call_idx = 0
def _fake_time():
nonlocal call_idx
# After one tick, jump past the deadline
if call_idx == 0:
call_idx += 1
return original_time()
# Return a value beyond the 180s timeout
return original_time() + 200
with patch(
"astrbot.core.computer.booters.shipyard_neo.asyncio.get_running_loop"
) as mock_loop:
mock_loop.return_value.time = _fake_time
with pytest.raises(TimeoutError, match="did not become ready"):
await booter._wait_until_ready(sandbox)
sandbox.delete.assert_awaited_once()
@pytest.mark.asyncio
async def test_delete_failure_during_cleanup_is_safe(self):
"""If sandbox.delete() itself throws, the original error is still raised."""
booter = self._make_booter()
sandbox = _make_sandbox_mock(
["failed"],
delete_side_effect=RuntimeError("Bay unreachable"),
)
with pytest.raises(RuntimeError, match="terminal state"):
await booter._wait_until_ready(sandbox)
sandbox.delete.assert_awaited_once()
# ═══════════════════════════════════════════════════════════════
# shutdown
# ═══════════════════════════════════════════════════════════════
class TestShutdown:
def _make_booter(self):
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
return ShipyardNeoBooter(
endpoint_url="http://localhost:8114",
access_token="sk-bay-test",
)
@pytest.mark.asyncio
async def test_delete_sandbox_true_calls_delete(self):
"""delete_sandbox=True → sandbox.delete() called, then client closed."""
booter = self._make_booter()
sandbox = SimpleNamespace(
id="sandbox-test-1",
delete=AsyncMock(),
)
client = SimpleNamespace(
__aexit__=AsyncMock(),
)
booter._sandbox = sandbox # type: ignore[assignment]
booter._client = client # type: ignore[assignment]
await booter.shutdown(delete_sandbox=True)
sandbox.delete.assert_awaited_once()
client.__aexit__.assert_awaited_once()
assert booter._client is None
assert booter._sandbox is None
@pytest.mark.asyncio
async def test_delete_sandbox_false_does_not_call_delete(self):
"""delete_sandbox=False (default) → sandbox.delete() NOT called."""
booter = self._make_booter()
sandbox = SimpleNamespace(
id="sandbox-test-1",
delete=AsyncMock(),
)
client = SimpleNamespace(
__aexit__=AsyncMock(),
)
booter._sandbox = sandbox # type: ignore[assignment]
booter._client = client # type: ignore[assignment]
await booter.shutdown() # default delete_sandbox=False
sandbox.delete.assert_not_called()
client.__aexit__.assert_awaited_once()
assert booter._client is None
assert booter._sandbox is None
@pytest.mark.asyncio
async def test_delete_failure_still_closes_client(self):
"""If sandbox.delete() throws, HTTP client is still torn down."""
booter = self._make_booter()
sandbox = SimpleNamespace(
id="sandbox-test-1",
delete=AsyncMock(side_effect=RuntimeError("Bay gone")),
)
client = SimpleNamespace(
__aexit__=AsyncMock(),
)
booter._sandbox = sandbox # type: ignore[assignment]
booter._client = client # type: ignore[assignment]
# Should not raise — delete failure is logged but swallowed
await booter.shutdown(delete_sandbox=True)
sandbox.delete.assert_awaited_once()
client.__aexit__.assert_awaited_once()
assert booter._client is None
assert booter._sandbox is None
@pytest.mark.asyncio
async def test_no_client_is_noop(self):
"""shutdown() on an uninitialised booter is a no-op."""
booter = self._make_booter()
# _client is None by default
await booter.shutdown(delete_sandbox=True)
# No exception → ok
# ═══════════════════════════════════════════════════════════════
# get_booter rebuild path
# ═══════════════════════════════════════════════════════════════
class TestGetBooterRebuild:
"""Verify that stale ShipyardNeoBooter instances are cleaned up on rebuild."""
def _make_fake_context(self, booter_type: str = "shipyard_neo"):
"""Build a context-like object for get_booter()."""
_cfg = {
"provider_settings": {
"computer_use_runtime": "sandbox",
"sandbox": {
"booter": booter_type,
"shipyard_neo_endpoint": "http://bay:8114",
"shipyard_neo_access_token": "sk-test",
"shipyard_neo_ttl": 3600,
"shipyard_neo_profile": "python-default",
},
}
}
return SimpleNamespace(
get_config=lambda umo=None: _cfg,
)
@pytest.mark.asyncio
async def test_stale_neo_booter_calls_shutdown_with_delete(self, monkeypatch):
"""A stale ShipyardNeoBooter gets shutdown(delete_sandbox=True) on eviction."""
from astrbot.core.computer import computer_client
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
ctx = self._make_fake_context()
stale = ShipyardNeoBooter(
endpoint_url="http://bay:8114", access_token="sk-test"
)
stale._sandbox = SimpleNamespace(id="stale-sandbox") # type: ignore[assignment]
stale._client = SimpleNamespace(__aexit__=AsyncMock()) # type: ignore[assignment]
stale._sandbox.refresh = AsyncMock(side_effect=RuntimeError("sandbox gone")) # type: ignore[union-attr]
# available() will return False because refresh() throws
stale.shutdown = AsyncMock()
monkeypatch.setitem(computer_client.session_booter, "session-1", stale)
from astrbot.core.computer.computer_client import get_booter
# get_booter should evict stale and rebuild.
# We need to mock the entire rebuild path so it doesn't actually
# try to connect to Bay.
async def _fake_boot(_self, _sid):
_self._sandbox = SimpleNamespace( # type: ignore[assignment]
id="new-sandbox",
refresh=AsyncMock(),
status=SimpleNamespace(value="ready"),
capabilities=["python", "shell", "filesystem"],
)
_self._client = SimpleNamespace() # type: ignore[assignment]
_self._shell = SimpleNamespace() # type: ignore[assignment]
_self._fs = SimpleNamespace() # type: ignore[assignment]
_self._python = SimpleNamespace() # type: ignore[assignment]
with patch.object(
ShipyardNeoBooter, "boot", _fake_boot
), patch(
"astrbot.core.computer.computer_client._sync_skills_to_sandbox",
AsyncMock(),
):
await get_booter(ctx, "session-1")
stale.shutdown.assert_awaited_once_with(delete_sandbox=True)
# Old entry should be replaced
new_booter = computer_client.session_booter.get("session-1")
assert new_booter is not None
assert new_booter is not stale
@pytest.mark.asyncio
async def test_stale_non_neo_booter_calls_plain_shutdown(self, monkeypatch):
"""Non-neo booter (e.g. shipyard) → plain shutdown() without delete_sandbox."""
from astrbot.core.computer import computer_client
ctx = self._make_fake_context(booter_type="shipyard")
stale = SimpleNamespace(shutdown=AsyncMock())
stale.available = AsyncMock(return_value=False)
monkeypatch.setitem(computer_client.session_booter, "session-1", stale)
# Patch ShipyardBooter entirely to skip its __init__ validation
class _FakeShipyardBooter:
def __init__(self, **kwargs):
pass
async def boot(self, _sid):
self._sandbox = SimpleNamespace( # type: ignore[assignment]
refresh=AsyncMock(),
status=SimpleNamespace(value="ready"),
)
self._shell = SimpleNamespace() # type: ignore[assignment]
self._fs = SimpleNamespace() # type: ignore[assignment]
self._python = SimpleNamespace() # type: ignore[assignment]
async def shutdown(self, **kwargs):
pass
with patch(
"astrbot.core.computer.booters.shipyard.ShipyardBooter",
_FakeShipyardBooter,
), patch(
"astrbot.core.computer.computer_client._sync_skills_to_sandbox",
AsyncMock(),
):
from astrbot.core.computer.computer_client import get_booter
await get_booter(ctx, "session-1")
stale.shutdown.assert_awaited_once()
# No delete_sandbox kwarg for non-neo booters
call_kwargs = stale.shutdown.call_args.kwargs
assert call_kwargs == {}

View File

@@ -259,6 +259,124 @@ class TestAstrBotCoreLifecycleErrorHandling:
)
class TestAstrBotCoreLifecycleDefaultChatProviderWarning:
"""Tests for startup warning when default chat provider is unset."""
@staticmethod
def _make_provider(provider_id: str):
provider = MagicMock()
provider.provider_config = {"id": provider_id}
return provider
def test_warns_for_multiple_enabled_chat_providers_without_default(
self, mock_log_broker, mock_db
):
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
provider_a = self._make_provider("openai_source/model-a")
provider_b = self._make_provider("openai_source/model-b")
lifecycle.provider_manager = MagicMock(
provider_settings={"default_provider_id": ""},
provider_insts=[provider_a, provider_b],
curr_provider_inst=provider_b,
)
with patch("astrbot.core.core_lifecycle.logger") as mock_logger:
lifecycle._warn_about_unset_default_chat_provider()
mock_logger.warning.assert_called_once()
assert mock_logger.warning.call_args[0][1] == 2
assert mock_logger.warning.call_args[0][2] == "openai_source/model-b"
def test_warns_only_once_per_lifecycle(self, mock_log_broker, mock_db):
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
lifecycle.provider_manager = MagicMock(
provider_settings={"default_provider_id": ""},
provider_insts=[
self._make_provider("openai_source/model-a"),
self._make_provider("openai_source/model-b"),
],
curr_provider_inst=self._make_provider("openai_source/model-a"),
)
with patch("astrbot.core.core_lifecycle.logger") as mock_logger:
lifecycle._warn_about_unset_default_chat_provider()
lifecycle._warn_about_unset_default_chat_provider()
mock_logger.warning.assert_called_once()
def test_does_not_warn_with_single_enabled_chat_provider_without_default(
self, mock_log_broker, mock_db
):
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
lifecycle.provider_manager = MagicMock(
provider_settings={"default_provider_id": ""},
provider_insts=[self._make_provider("openai_source/model-a")],
curr_provider_inst=self._make_provider("openai_source/model-a"),
)
with patch("astrbot.core.core_lifecycle.logger") as mock_logger:
lifecycle._warn_about_unset_default_chat_provider()
mock_logger.warning.assert_not_called()
def test_does_not_warn_when_default_chat_provider_is_set(
self, mock_log_broker, mock_db
):
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
lifecycle.provider_manager = MagicMock(
provider_settings={"default_provider_id": "openai_source/model-a"},
provider_insts=[
self._make_provider("openai_source/model-a"),
self._make_provider("openai_source/model-b"),
],
curr_provider_inst=self._make_provider("openai_source/model-a"),
)
with patch("astrbot.core.core_lifecycle.logger") as mock_logger:
lifecycle._warn_about_unset_default_chat_provider()
mock_logger.warning.assert_not_called()
def test_warns_and_fallbacks_to_first_provider_when_curr_provider_inst_is_none(
self, mock_log_broker, mock_db
):
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
provider_a = self._make_provider("openai_source/model-a")
provider_b = self._make_provider("openai_source/model-b")
lifecycle.provider_manager = MagicMock(
provider_settings={"default_provider_id": ""},
provider_insts=[provider_a, provider_b],
curr_provider_inst=None,
)
with patch("astrbot.core.core_lifecycle.logger") as mock_logger:
lifecycle._warn_about_unset_default_chat_provider()
mock_logger.warning.assert_called_once()
assert mock_logger.warning.call_args[0][1] == 2
assert mock_logger.warning.call_args[0][2] == "openai_source/model-a"
def test_warns_when_default_provider_id_does_not_match_any_enabled_provider(
self, mock_log_broker, mock_db
):
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
lifecycle.provider_manager = MagicMock(
provider_settings={"default_provider_id": "non-existent-id"},
provider_insts=[
self._make_provider("openai_source/model-a"),
self._make_provider("openai_source/model-b"),
],
curr_provider_inst=self._make_provider("openai_source/model-b"),
)
with patch("astrbot.core.core_lifecycle.logger") as mock_logger:
lifecycle._warn_about_unset_default_chat_provider()
mock_logger.warning.assert_called_once()
assert mock_logger.warning.call_args[0][1] == "non-existent-id"
assert mock_logger.warning.call_args[0][2] == "openai_source/model-b"
class TestAstrBotCoreLifecycleInitialize:
"""Tests for AstrBotCoreLifecycle.initialize method."""

View File

@@ -56,7 +56,9 @@ async def test_execute_shell_defaults_to_foreground(monkeypatch):
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
async def exec(
self, command, cwd=None, background=False, env=None, timeout=None
):
calls.append({"command": command, "background": background})
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
@@ -98,7 +100,9 @@ async def test_execute_shell_uses_fresh_default_env_per_call(monkeypatch):
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
async def exec(
self, command, cwd=None, background=False, env=None, timeout=None
):
env["MUTATED_BY_FAKE_SHELL"] = command
calls.append(env)
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
@@ -142,7 +146,9 @@ async def test_execute_shell_copies_user_env_before_execution(monkeypatch):
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
async def exec(
self, command, cwd=None, background=False, env=None, timeout=None
):
env["MUTATED_BY_FAKE_SHELL"] = command
calls.append(env)
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
@@ -186,7 +192,9 @@ async def test_execute_shell_avoids_double_background_for_detached_commands(
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
async def exec(
self, command, cwd=None, background=False, env=None, timeout=None
):
calls.append({"command": command, "background": background})
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
@@ -229,7 +237,9 @@ async def test_execute_shell_recognizes_commented_background_command(monkeypatch
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
async def exec(
self, command, cwd=None, background=False, env=None, timeout=None
):
calls.append({"command": command, "background": background})
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
@@ -292,7 +302,9 @@ async def test_execute_shell_reports_blank_exception_type(monkeypatch):
return ""
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
async def exec(
self, command, cwd=None, background=False, env=None, timeout=None
):
raise BlankError()
class FakeBooter: