mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-02 10:40:15 +08:00
Compare commits
15 Commits
perf/shell
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa9897cacb | ||
|
|
1835467544 | ||
|
|
34dc91e4b0 | ||
|
|
938c241799 | ||
|
|
71b6349b6a | ||
|
|
7c185f8e40 | ||
|
|
6756a669d7 | ||
|
|
587286a967 | ||
|
|
eb69bf3687 | ||
|
|
6b36e1abac | ||
|
|
8f356b84c7 | ||
|
|
98b05b7e89 | ||
|
|
962c299c2d | ||
|
|
66d620dab5 | ||
|
|
ac7f6aa60d |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -1,5 +0,0 @@
|
||||
name: session_controller
|
||||
desc: 为插件支持会话控制
|
||||
author: Cvandia & Soulter
|
||||
version: v1.0.1
|
||||
repo: https://astrbot.app
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
18
astrbot/core/computer/booters/shell_background.py
Normal file
18
astrbot/core/computer/booters/shell_background.py
Normal 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)}"
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
149
dashboard/src/components/shared/OutlinedActionListItem.vue
Normal file
149
dashboard/src/components/shared/OutlinedActionListItem.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"title": "Knowledge Base Details",
|
||||
"backToList": "Back to List",
|
||||
"breadcrumb": {
|
||||
"list": "Knowledge Bases"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"documents": "Documents",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"mcpServers": {
|
||||
"title": "MCP Servers",
|
||||
"description": "Manage MCP servers",
|
||||
"buttons": {
|
||||
"refresh": "Refresh",
|
||||
"add": "Add Server",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"title": "Детали базы знаний",
|
||||
"backToList": "К списку",
|
||||
"breadcrumb": {
|
||||
"list": "Базы знаний"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Обзор",
|
||||
"documents": "Документы",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"title": "Управление базами знаний",
|
||||
"subtitle": "Централизованное управление всеми знаниями AstrBot",
|
||||
"list": {
|
||||
"title": "Мои базы знаний",
|
||||
"title": "Базы знаний",
|
||||
"subtitle": "Все доступные коллекции знаний",
|
||||
"create": "Создать базу",
|
||||
"refresh": "Обновить",
|
||||
@@ -65,4 +65,4 @@
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"loadError": "Не удалось загрузить список"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"mcpServers": {
|
||||
"title": "MCP Сервера",
|
||||
"description": "Управление MCP-серверами",
|
||||
"buttons": {
|
||||
"refresh": "Обновить",
|
||||
"add": "Добавить сервер",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"title": "知识库详情",
|
||||
"backToList": "返回列表",
|
||||
"breadcrumb": {
|
||||
"list": "知识库"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "概览",
|
||||
"documents": "文档管理",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"title": "知识库管理",
|
||||
"subtitle": "统一管理和查询知识库内容",
|
||||
"list": {
|
||||
"title": "我的知识库",
|
||||
"title": "知识库",
|
||||
"subtitle": "管理您的所有知识库集合",
|
||||
"create": "创建知识库",
|
||||
"refresh": "刷新列表",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"mcpServers": {
|
||||
"title": "MCP 服务器",
|
||||
"description": "管理 MCP 服务器",
|
||||
"buttons": {
|
||||
"refresh": "刷新",
|
||||
"add": "新增服务器",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const EXTENSION_ROUTE_NAME = 'Extensions';
|
||||
export const EXTENSION_DETAILS_ROUTE_NAME = 'ExtensionDetails';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
954
dashboard/src/views/extension/PluginDetailPage.vue
Normal file
954
dashboard/src/views/extension/PluginDetailPage.vue
Normal 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>
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'], {}));
|
||||
});
|
||||
|
||||
@@ -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"}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
344
tests/test_shipyard_neo_booter.py
Normal file
344
tests/test_shipyard_neo_booter.py
Normal 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 == {}
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user