Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84994b5d98 | |||
| 1554f71106 | |||
| 476c01469f | |||
| 10163ec78a | |||
| 98b89ebcc5 | |||
| 39b9e55434 | |||
| 3eb15089af | |||
| c5b23d12a8 | |||
| 69f2fb291a | |||
| 78660da995 | |||
| c951b14aa2 | |||
| c384439b44 | |||
| 87d2750ff8 | |||
| 6d76d55452 | |||
| d80598b9c3 | |||
| c7d318304b | |||
| bcdbc15635 | |||
| 4749159bb9 | |||
| 5530a2260a | |||
| c24de24ca4 | |||
| b54b4c79ed | |||
| c6cc7aae84 | |||
| 84cd209074 | |||
| afda44fbe3 | |||
| f5d3b93437 | |||
| 069a3628fa | |||
| c81ef2672a | |||
| a5ae27cae0 | |||
| 73faaf6577 | |||
| 29dbd085d4 | |||
| 00b011809a | |||
| 0b46ca7ff3 | |||
| 9294b44831 | |||
| 80fd51119b | |||
| 5af5ad9e36 | |||
| 7b731ebda8 |
@@ -37,7 +37,7 @@ jobs:
|
||||
mkdir -p data/temp
|
||||
export TESTING=true
|
||||
export ZHIPU_API_KEY=${{ secrets.OPENAI_API_KEY }}
|
||||
pytest --cov=. -v -o log_cli=true -o log_level=DEBUG
|
||||
pytest --cov=astrbot -v -o log_cli=true -o log_level=DEBUG
|
||||
|
||||
- name: Upload results to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
|
||||
@@ -206,16 +206,33 @@ class ConversationCommands:
|
||||
_titles[conv.cid] = title
|
||||
|
||||
"""遍历分页后的对话生成列表显示"""
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
platform_name = message.get_platform_name()
|
||||
for conv in conversations_paged:
|
||||
persona_id = conv.persona_id
|
||||
if not persona_id or persona_id == "[%None]":
|
||||
persona = await self.context.persona_manager.get_default_persona_v3(
|
||||
umo=message.unified_msg_origin,
|
||||
)
|
||||
persona_id = persona["name"]
|
||||
(
|
||||
persona_id,
|
||||
_,
|
||||
force_applied_persona_id,
|
||||
_,
|
||||
) = await self.context.persona_manager.resolve_selected_persona(
|
||||
umo=message.unified_msg_origin,
|
||||
conversation_persona_id=conv.persona_id,
|
||||
platform_name=platform_name,
|
||||
provider_settings=provider_settings,
|
||||
)
|
||||
if persona_id == "[%None]":
|
||||
persona_name = "无"
|
||||
elif persona_id:
|
||||
persona_name = persona_id
|
||||
else:
|
||||
persona_name = "无"
|
||||
|
||||
if force_applied_persona_id:
|
||||
persona_name = f"{persona_name} (自定义规则)"
|
||||
|
||||
title = _titles.get(conv.cid, "新对话")
|
||||
parts.append(
|
||||
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_id}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
|
||||
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
|
||||
)
|
||||
global_index += 1
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import builtins
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot.api import sp, star
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -59,12 +59,7 @@ class PersonaCommands:
|
||||
default_persona = await self.context.persona_manager.get_default_persona_v3(
|
||||
umo=umo,
|
||||
)
|
||||
|
||||
force_applied_persona_id = (
|
||||
await sp.get_async(
|
||||
scope="umo", scope_id=umo, key="session_service_config", default={}
|
||||
)
|
||||
).get("persona_id")
|
||||
force_applied_persona_id = None
|
||||
|
||||
curr_cid_title = "无"
|
||||
if cid:
|
||||
@@ -80,10 +75,27 @@ class PersonaCommands:
|
||||
),
|
||||
)
|
||||
return
|
||||
if not conv.persona_id and conv.persona_id != "[%None]":
|
||||
curr_persona_name = default_persona["name"]
|
||||
else:
|
||||
curr_persona_name = conv.persona_id
|
||||
|
||||
provider_settings = self.context.get_config(umo=umo).get(
|
||||
"provider_settings",
|
||||
{},
|
||||
)
|
||||
(
|
||||
persona_id,
|
||||
_,
|
||||
force_applied_persona_id,
|
||||
_,
|
||||
) = await self.context.persona_manager.resolve_selected_persona(
|
||||
umo=umo,
|
||||
conversation_persona_id=conv.persona_id,
|
||||
platform_name=message.get_platform_name(),
|
||||
provider_settings=provider_settings,
|
||||
)
|
||||
|
||||
if persona_id == "[%None]":
|
||||
curr_persona_name = "无"
|
||||
elif persona_id:
|
||||
curr_persona_name = persona_id
|
||||
|
||||
if force_applied_persona_id:
|
||||
curr_persona_name = f"{curr_persona_name} (自定义规则)"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.18.1"
|
||||
__version__ = "4.18.3"
|
||||
|
||||
@@ -4,19 +4,60 @@ from ..message import Message
|
||||
class ContextTruncator:
|
||||
"""Context truncator."""
|
||||
|
||||
def _has_tool_calls(self, message: Message) -> bool:
|
||||
"""Check if a message contains tool calls."""
|
||||
return (
|
||||
message.role == "assistant"
|
||||
and message.tool_calls is not None
|
||||
and len(message.tool_calls) > 0
|
||||
)
|
||||
|
||||
def fix_messages(self, messages: list[Message]) -> list[Message]:
|
||||
fixed_messages = []
|
||||
for message in messages:
|
||||
if message.role == "tool":
|
||||
# tool block 前面必须要有 user 和 assistant block
|
||||
if len(fixed_messages) < 2:
|
||||
# 这种情况可能是上下文被截断导致的
|
||||
# 我们直接将之前的上下文都清空
|
||||
fixed_messages = []
|
||||
else:
|
||||
fixed_messages.append(message)
|
||||
else:
|
||||
fixed_messages.append(message)
|
||||
"""修复消息列表,确保 tool call 和 tool response 的配对关系有效。
|
||||
|
||||
此方法确保:
|
||||
1. 每个 `tool` 消息前面都有一个包含 tool_calls 的 `assistant` 消息
|
||||
2. 每个包含 tool_calls 的 `assistant` 消息后面都有对应的 `tool` 响应
|
||||
|
||||
这是 OpenAI Chat Completions API 规范的要求(Gemini 对此执行严格检查)。
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
fixed_messages: list[Message] = []
|
||||
pending_assistant: Message | None = None
|
||||
pending_tools: list[Message] = []
|
||||
|
||||
def flush_pending_if_valid() -> None:
|
||||
nonlocal pending_assistant, pending_tools
|
||||
if pending_assistant is not None and pending_tools:
|
||||
fixed_messages.append(pending_assistant)
|
||||
fixed_messages.extend(pending_tools)
|
||||
pending_assistant = None
|
||||
pending_tools = []
|
||||
|
||||
for msg in messages:
|
||||
if msg.role == "tool":
|
||||
# 只有在有挂起的 assistant(tool_calls) 时才记录 tool 响应
|
||||
if pending_assistant is not None:
|
||||
pending_tools.append(msg)
|
||||
# else: 孤立的 tool 消息,直接忽略
|
||||
continue
|
||||
|
||||
if self._has_tool_calls(msg):
|
||||
# 遇到新的 assistant(tool_calls) 前,先处理旧的 pending 链
|
||||
flush_pending_if_valid()
|
||||
pending_assistant = msg
|
||||
continue
|
||||
|
||||
# 非 tool,且不含 tool_calls 的消息
|
||||
# 先结束任何 pending 链,再正常追加
|
||||
flush_pending_if_valid()
|
||||
fixed_messages.append(msg)
|
||||
|
||||
# 结束时处理最后一个 pending 链
|
||||
flush_pending_if_valid()
|
||||
|
||||
return fixed_messages
|
||||
|
||||
def truncate_by_turns(
|
||||
|
||||
@@ -24,15 +24,77 @@ def _should_stop_agent(astr_event) -> bool:
|
||||
return astr_event.is_stopped() or bool(astr_event.get_extra("agent_stop_requested"))
|
||||
|
||||
|
||||
def _truncate_tool_result(text: str, limit: int = 70) -> str:
|
||||
if limit <= 0:
|
||||
return ""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
if limit <= 3:
|
||||
return text[:limit]
|
||||
return f"{text[: limit - 3]}..."
|
||||
|
||||
|
||||
def _extract_chain_json_data(msg_chain: MessageChain) -> dict | None:
|
||||
if not msg_chain.chain:
|
||||
return None
|
||||
first_comp = msg_chain.chain[0]
|
||||
if isinstance(first_comp, Json) and isinstance(first_comp.data, dict):
|
||||
return first_comp.data
|
||||
return None
|
||||
|
||||
|
||||
def _record_tool_call_name(
|
||||
tool_info: dict | None, tool_name_by_call_id: dict[str, str]
|
||||
) -> None:
|
||||
if not isinstance(tool_info, dict):
|
||||
return
|
||||
tool_call_id = tool_info.get("id")
|
||||
tool_name = tool_info.get("name")
|
||||
if tool_call_id is None or tool_name is None:
|
||||
return
|
||||
tool_name_by_call_id[str(tool_call_id)] = str(tool_name)
|
||||
|
||||
|
||||
def _build_tool_call_status_message(tool_info: dict | None) -> str:
|
||||
if tool_info:
|
||||
return f"🔨 调用工具: {tool_info.get('name', 'unknown')}"
|
||||
return "🔨 调用工具..."
|
||||
|
||||
|
||||
def _build_tool_result_status_message(
|
||||
msg_chain: MessageChain, tool_name_by_call_id: dict[str, str]
|
||||
) -> str:
|
||||
tool_name = "unknown"
|
||||
tool_result = ""
|
||||
|
||||
result_data = _extract_chain_json_data(msg_chain)
|
||||
if result_data:
|
||||
tool_call_id = result_data.get("id")
|
||||
if tool_call_id is not None:
|
||||
tool_name = tool_name_by_call_id.pop(str(tool_call_id), "unknown")
|
||||
tool_result = str(result_data.get("result", ""))
|
||||
|
||||
if not tool_result:
|
||||
tool_result = msg_chain.get_plain_text(with_other_comps_mark=True)
|
||||
tool_result = _truncate_tool_result(tool_result, 70)
|
||||
|
||||
status_msg = f"🔨 调用工具: {tool_name}"
|
||||
if tool_result:
|
||||
status_msg = f"{status_msg}\n📎 返回结果: {tool_result}"
|
||||
return status_msg
|
||||
|
||||
|
||||
async def run_agent(
|
||||
agent_runner: AgentRunner,
|
||||
max_step: int = 30,
|
||||
show_tool_use: bool = True,
|
||||
show_tool_call_result: bool = False,
|
||||
stream_to_general: bool = False,
|
||||
show_reasoning: bool = False,
|
||||
) -> AsyncGenerator[MessageChain | None, None]:
|
||||
step_idx = 0
|
||||
astr_event = agent_runner.run_context.context.event
|
||||
tool_name_by_call_id: dict[str, str] = {}
|
||||
while step_idx < max_step + 1:
|
||||
step_idx += 1
|
||||
|
||||
@@ -90,6 +152,13 @@ async def run_agent(
|
||||
continue
|
||||
if astr_event.get_platform_id() == "webchat":
|
||||
await astr_event.send(msg_chain)
|
||||
elif show_tool_use and show_tool_call_result:
|
||||
status_msg = _build_tool_result_status_message(
|
||||
msg_chain, tool_name_by_call_id
|
||||
)
|
||||
await astr_event.send(
|
||||
MessageChain(type="tool_call").message(status_msg)
|
||||
)
|
||||
# 对于其他情况,暂时先不处理
|
||||
continue
|
||||
elif resp.type == "tool_call":
|
||||
@@ -97,25 +166,22 @@ async def run_agent(
|
||||
# 用来标记流式响应需要分节
|
||||
yield MessageChain(chain=[], type="break")
|
||||
|
||||
tool_info = None
|
||||
|
||||
if resp.data["chain"].chain:
|
||||
json_comp = resp.data["chain"].chain[0]
|
||||
if isinstance(json_comp, Json):
|
||||
tool_info = json_comp.data
|
||||
astr_event.trace.record(
|
||||
"agent_tool_call",
|
||||
tool_name=tool_info if tool_info else "unknown",
|
||||
)
|
||||
tool_info = _extract_chain_json_data(resp.data["chain"])
|
||||
astr_event.trace.record(
|
||||
"agent_tool_call",
|
||||
tool_name=tool_info if tool_info else "unknown",
|
||||
)
|
||||
_record_tool_call_name(tool_info, tool_name_by_call_id)
|
||||
|
||||
if astr_event.get_platform_name() == "webchat":
|
||||
await astr_event.send(resp.data["chain"])
|
||||
elif show_tool_use:
|
||||
if tool_info:
|
||||
m = f"🔨 调用工具: {tool_info.get('name', 'unknown')}"
|
||||
else:
|
||||
m = "🔨 调用工具..."
|
||||
chain = MessageChain(type="tool_call").message(m)
|
||||
if show_tool_call_result and isinstance(tool_info, dict):
|
||||
# Delay tool status notification until tool_call_result.
|
||||
continue
|
||||
chain = MessageChain(type="tool_call").message(
|
||||
_build_tool_call_status_message(tool_info)
|
||||
)
|
||||
await astr_event.send(chain)
|
||||
continue
|
||||
|
||||
@@ -202,6 +268,7 @@ async def run_live_agent(
|
||||
tts_provider: TTSProvider | None = None,
|
||||
max_step: int = 30,
|
||||
show_tool_use: bool = True,
|
||||
show_tool_call_result: bool = False,
|
||||
show_reasoning: bool = False,
|
||||
) -> AsyncGenerator[MessageChain | None, None]:
|
||||
"""Live Mode 的 Agent 运行器,支持流式 TTS
|
||||
@@ -211,6 +278,7 @@ async def run_live_agent(
|
||||
tts_provider: TTS Provider 实例
|
||||
max_step: 最大步数
|
||||
show_tool_use: 是否显示工具使用
|
||||
show_tool_call_result: 是否显示工具返回结果
|
||||
show_reasoning: 是否显示推理过程
|
||||
|
||||
Yields:
|
||||
@@ -222,6 +290,7 @@ async def run_live_agent(
|
||||
agent_runner,
|
||||
max_step=max_step,
|
||||
show_tool_use=show_tool_use,
|
||||
show_tool_call_result=show_tool_call_result,
|
||||
stream_to_general=False,
|
||||
show_reasoning=show_reasoning,
|
||||
):
|
||||
@@ -250,7 +319,12 @@ async def run_live_agent(
|
||||
# 1. 启动 Agent Feeder 任务:负责运行 Agent 并将文本分句喂给 text_queue
|
||||
feeder_task = asyncio.create_task(
|
||||
_run_agent_feeder(
|
||||
agent_runner, text_queue, max_step, show_tool_use, show_reasoning
|
||||
agent_runner,
|
||||
text_queue,
|
||||
max_step,
|
||||
show_tool_use,
|
||||
show_tool_call_result,
|
||||
show_reasoning,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -336,6 +410,7 @@ async def _run_agent_feeder(
|
||||
text_queue: asyncio.Queue,
|
||||
max_step: int,
|
||||
show_tool_use: bool,
|
||||
show_tool_call_result: bool,
|
||||
show_reasoning: bool,
|
||||
) -> None:
|
||||
"""运行 Agent 并将文本输出分句放入队列"""
|
||||
@@ -345,6 +420,7 @@ async def _run_agent_feeder(
|
||||
agent_runner,
|
||||
max_step=max_step,
|
||||
show_tool_use=show_tool_use,
|
||||
show_tool_call_result=show_tool_call_result,
|
||||
stream_to_general=False,
|
||||
show_reasoning=show_reasoning,
|
||||
):
|
||||
|
||||
@@ -17,6 +17,12 @@ from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.astr_main_agent_resources import (
|
||||
BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
|
||||
EXECUTE_SHELL_TOOL,
|
||||
FILE_DOWNLOAD_TOOL,
|
||||
FILE_UPLOAD_TOOL,
|
||||
LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL,
|
||||
PYTHON_TOOL,
|
||||
SEND_MESSAGE_TO_USER_TOOL,
|
||||
)
|
||||
from astrbot.core.cron.events import CronMessageEvent
|
||||
@@ -91,6 +97,65 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
yield r
|
||||
return
|
||||
|
||||
@classmethod
|
||||
def _get_runtime_computer_tools(cls, runtime: str) -> dict[str, FunctionTool]:
|
||||
if runtime == "sandbox":
|
||||
return {
|
||||
EXECUTE_SHELL_TOOL.name: EXECUTE_SHELL_TOOL,
|
||||
PYTHON_TOOL.name: PYTHON_TOOL,
|
||||
FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL,
|
||||
FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL,
|
||||
}
|
||||
if runtime == "local":
|
||||
return {
|
||||
LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL,
|
||||
}
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _build_handoff_toolset(
|
||||
cls,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
tools: list[str | FunctionTool] | None,
|
||||
) -> ToolSet | None:
|
||||
ctx = run_context.context.context
|
||||
event = run_context.context.event
|
||||
cfg = ctx.get_config(umo=event.unified_msg_origin)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
runtime = str(provider_settings.get("computer_use_runtime", "local"))
|
||||
runtime_computer_tools = cls._get_runtime_computer_tools(runtime)
|
||||
|
||||
# Keep persona semantics aligned with the main agent: tools=None means
|
||||
# "all tools", including runtime computer-use tools.
|
||||
if tools is None:
|
||||
toolset = ToolSet()
|
||||
for registered_tool in llm_tools.func_list:
|
||||
if isinstance(registered_tool, HandoffTool):
|
||||
continue
|
||||
if registered_tool.active:
|
||||
toolset.add_tool(registered_tool)
|
||||
for runtime_tool in runtime_computer_tools.values():
|
||||
toolset.add_tool(runtime_tool)
|
||||
return None if toolset.empty() else toolset
|
||||
|
||||
if not tools:
|
||||
return None
|
||||
|
||||
toolset = ToolSet()
|
||||
for tool_name_or_obj in tools:
|
||||
if isinstance(tool_name_or_obj, str):
|
||||
registered_tool = llm_tools.get_func(tool_name_or_obj)
|
||||
if registered_tool and registered_tool.active:
|
||||
toolset.add_tool(registered_tool)
|
||||
continue
|
||||
runtime_tool = runtime_computer_tools.get(tool_name_or_obj)
|
||||
if runtime_tool:
|
||||
toolset.add_tool(runtime_tool)
|
||||
elif isinstance(tool_name_or_obj, FunctionTool):
|
||||
toolset.add_tool(tool_name_or_obj)
|
||||
return None if toolset.empty() else toolset
|
||||
|
||||
@classmethod
|
||||
async def _execute_handoff(
|
||||
cls,
|
||||
@@ -101,19 +166,8 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
input_ = tool_args.get("input")
|
||||
image_urls = tool_args.get("image_urls")
|
||||
|
||||
# make toolset for the agent
|
||||
tools = tool.agent.tools
|
||||
if tools:
|
||||
toolset = ToolSet()
|
||||
for t in tools:
|
||||
if isinstance(t, str):
|
||||
_t = llm_tools.get_func(t)
|
||||
if _t:
|
||||
toolset.add_tool(_t)
|
||||
elif isinstance(t, FunctionTool):
|
||||
toolset.add_tool(t)
|
||||
else:
|
||||
toolset = None
|
||||
# Build handoff toolset from registered tools plus runtime computer tools.
|
||||
toolset = cls._build_handoff_toolset(run_context, tool.agent.tools)
|
||||
|
||||
ctx = run_context.context.context
|
||||
event = run_context.context.event
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import builtins
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
@@ -10,7 +9,6 @@ import zoneinfo
|
||||
from collections.abc import Coroutine
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from astrbot.api import sp
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.agent.handoff import HandoffTool
|
||||
from astrbot.core.agent.mcp_client import MCPTool
|
||||
@@ -275,47 +273,26 @@ async def _ensure_persona_and_skills(
|
||||
if not req.conversation:
|
||||
return
|
||||
|
||||
# get persona ID
|
||||
|
||||
# 1. from session service config - highest priority
|
||||
persona_id = (
|
||||
await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=event.unified_msg_origin,
|
||||
key="session_service_config",
|
||||
default={},
|
||||
)
|
||||
).get("persona_id")
|
||||
|
||||
if not persona_id:
|
||||
# 2. from conversation setting - second priority
|
||||
persona_id = req.conversation.persona_id
|
||||
|
||||
if persona_id == "[%None]":
|
||||
# explicitly set to no persona
|
||||
pass
|
||||
elif persona_id is None:
|
||||
# 3. from config default persona setting - last priority
|
||||
persona_id = cfg.get("default_personality")
|
||||
|
||||
persona = next(
|
||||
builtins.filter(
|
||||
lambda persona: persona["name"] == persona_id,
|
||||
plugin_context.persona_manager.personas_v3,
|
||||
),
|
||||
None,
|
||||
(
|
||||
persona_id,
|
||||
persona,
|
||||
_,
|
||||
use_webchat_special_default,
|
||||
) = await plugin_context.persona_manager.resolve_selected_persona(
|
||||
umo=event.unified_msg_origin,
|
||||
conversation_persona_id=req.conversation.persona_id,
|
||||
platform_name=event.get_platform_name(),
|
||||
provider_settings=cfg,
|
||||
)
|
||||
|
||||
if persona:
|
||||
# Inject persona system prompt
|
||||
if prompt := persona["prompt"]:
|
||||
req.system_prompt += f"\n# Persona Instructions\n\n{prompt}\n"
|
||||
if begin_dialogs := copy.deepcopy(persona.get("_begin_dialogs_processed")):
|
||||
req.contexts[:0] = begin_dialogs
|
||||
else:
|
||||
# special handling for webchat persona
|
||||
if event.get_platform_name() == "webchat" and persona_id != "[%None]":
|
||||
persona_id = "_chatui_default_"
|
||||
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
|
||||
elif use_webchat_special_default:
|
||||
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
|
||||
|
||||
# Inject skills prompt
|
||||
runtime = cfg.get("computer_use_runtime", "local")
|
||||
|
||||
@@ -11,6 +11,7 @@ from astrbot.core.message.components import File
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from ..computer_client import get_booter
|
||||
from .permissions import check_admin_permission
|
||||
|
||||
# @dataclass
|
||||
# class CreateFileTool(FunctionTool):
|
||||
@@ -102,6 +103,8 @@ class FileUploadTool(FunctionTool):
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
local_path: str,
|
||||
) -> str | None:
|
||||
if permission_error := check_admin_permission(context, "File upload/download"):
|
||||
return permission_error
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
@@ -161,6 +164,8 @@ class FileDownloadTool(FunctionTool):
|
||||
remote_path: str,
|
||||
also_send_to_user: bool = True,
|
||||
) -> ToolExecResult:
|
||||
if permission_error := check_admin_permission(context, "File upload/download"):
|
||||
return permission_error
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
|
||||
def check_admin_permission(
|
||||
context: ContextWrapper[AstrAgentContext], operation_name: str
|
||||
) -> str | None:
|
||||
cfg = context.context.context.get_config(
|
||||
umo=context.context.event.unified_msg_origin
|
||||
)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
require_admin = provider_settings.get("computer_use_require_admin", True)
|
||||
if require_admin and context.context.event.role != "admin":
|
||||
return (
|
||||
f"error: Permission denied. {operation_name} is only allowed for admin users. "
|
||||
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature. "
|
||||
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
|
||||
)
|
||||
return None
|
||||
@@ -7,6 +7,7 @@ from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent
|
||||
from astrbot.core.computer.computer_client import get_booter, get_local_booter
|
||||
from astrbot.core.computer.tools.permissions import check_admin_permission
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
|
||||
param_schema = {
|
||||
@@ -26,21 +27,6 @@ param_schema = {
|
||||
}
|
||||
|
||||
|
||||
def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> str | None:
|
||||
cfg = context.context.context.get_config(
|
||||
umo=context.context.event.unified_msg_origin
|
||||
)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
require_admin = provider_settings.get("computer_use_require_admin", True)
|
||||
if require_admin and context.context.event.role != "admin":
|
||||
return (
|
||||
"error: Permission denied. Python execution is only allowed for admin users. "
|
||||
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature."
|
||||
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult:
|
||||
data = result.get("data", {})
|
||||
output = data.get("output", {})
|
||||
@@ -81,7 +67,7 @@ class PythonTool(FunctionTool):
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
||||
) -> ToolExecResult:
|
||||
if permission_error := _check_admin_permission(context):
|
||||
if permission_error := check_admin_permission(context, "Python execution"):
|
||||
return permission_error
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
@@ -104,7 +90,7 @@ class LocalPythonTool(FunctionTool):
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
||||
) -> ToolExecResult:
|
||||
if permission_error := _check_admin_permission(context):
|
||||
if permission_error := check_admin_permission(context, "Python execution"):
|
||||
return permission_error
|
||||
sb = get_local_booter()
|
||||
try:
|
||||
|
||||
@@ -7,21 +7,7 @@ from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
from ..computer_client import get_booter, get_local_booter
|
||||
|
||||
|
||||
def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> str | None:
|
||||
cfg = context.context.context.get_config(
|
||||
umo=context.context.event.unified_msg_origin
|
||||
)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
require_admin = provider_settings.get("computer_use_require_admin", True)
|
||||
if require_admin and context.context.event.role != "admin":
|
||||
return (
|
||||
"error: Permission denied. Shell execution is only allowed for admin users. "
|
||||
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature."
|
||||
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
|
||||
)
|
||||
return None
|
||||
from .permissions import check_admin_permission
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -61,7 +47,7 @@ class ExecuteShellTool(FunctionTool):
|
||||
background: bool = False,
|
||||
env: dict = {},
|
||||
) -> ToolExecResult:
|
||||
if permission_error := _check_admin_permission(context):
|
||||
if permission_error := check_admin_permission(context, "Shell execution"):
|
||||
return permission_error
|
||||
|
||||
if self.is_local:
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.18.1"
|
||||
VERSION = "4.18.3"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -100,6 +100,7 @@ DEFAULT_CONFIG = {
|
||||
"dequeue_context_length": 1,
|
||||
"streaming_response": False,
|
||||
"show_tool_use_status": False,
|
||||
"show_tool_call_result": False,
|
||||
"sanitize_context_by_modalities": False,
|
||||
"max_quoted_fallback_images": 20,
|
||||
"quoted_message_parser": {
|
||||
@@ -424,7 +425,15 @@ CONFIG_METADATA_2 = {
|
||||
"slack_webhook_port": 6197,
|
||||
"slack_webhook_path": "/astrbot-slack-webhook/callback",
|
||||
},
|
||||
# LINE's config is located in line_adapter.py
|
||||
"Line": {
|
||||
"id": "line",
|
||||
"type": "line",
|
||||
"enable": False,
|
||||
"channel_access_token": "",
|
||||
"channel_secret": "",
|
||||
"unified_webhook_mode": True,
|
||||
"webhook_uuid": "",
|
||||
},
|
||||
"Satori": {
|
||||
"id": "satori",
|
||||
"type": "satori",
|
||||
@@ -1462,6 +1471,7 @@ CONFIG_METADATA_2 = {
|
||||
"type": "openai_embedding",
|
||||
"provider": "openai",
|
||||
"provider_type": "embedding",
|
||||
"hint": "provider_group.provider.openai_embedding.hint",
|
||||
"enable": True,
|
||||
"embedding_api_key": "",
|
||||
"embedding_api_base": "",
|
||||
@@ -1475,6 +1485,7 @@ CONFIG_METADATA_2 = {
|
||||
"type": "gemini_embedding",
|
||||
"provider": "google",
|
||||
"provider_type": "embedding",
|
||||
"hint": "provider_group.provider.gemini_embedding.hint",
|
||||
"enable": True,
|
||||
"embedding_api_key": "",
|
||||
"embedding_api_base": "",
|
||||
@@ -2191,9 +2202,9 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
},
|
||||
"proxy": {
|
||||
"description": "代理地址",
|
||||
"description": "provider_group.provider.proxy.description",
|
||||
"type": "string",
|
||||
"hint": "HTTP/HTTPS 代理地址,格式如 http://127.0.0.1:7890。仅对该提供商的 API 请求生效,不影响 Docker 内网通信。",
|
||||
"hint": "provider_group.provider.proxy.hint",
|
||||
},
|
||||
"model": {
|
||||
"description": "模型 ID",
|
||||
@@ -2306,6 +2317,9 @@ CONFIG_METADATA_2 = {
|
||||
"show_tool_use_status": {
|
||||
"type": "bool",
|
||||
},
|
||||
"show_tool_call_result": {
|
||||
"type": "bool",
|
||||
},
|
||||
"unsupported_streaming_strategy": {
|
||||
"type": "string",
|
||||
},
|
||||
@@ -2994,6 +3008,15 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.show_tool_call_result": {
|
||||
"description": "输出函数调用返回结果",
|
||||
"type": "bool",
|
||||
"hint": "仅在输出函数调用状态启用时生效,展示结果前 70 个字符。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
"provider_settings.show_tool_use_status": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.sanitize_context_by_modalities": {
|
||||
"description": "按模型能力清理历史上下文",
|
||||
"type": "bool",
|
||||
|
||||
@@ -4,7 +4,7 @@ import typing as T
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import CursorResult
|
||||
from sqlalchemy import CursorResult, Row
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import col, delete, desc, func, or_, select, text, update
|
||||
|
||||
@@ -626,7 +626,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
query = select(ApiKey).where(
|
||||
ApiKey.key_hash == key_hash,
|
||||
col(ApiKey.revoked_at).is_(None),
|
||||
or_(col(ApiKey.expires_at).is_(None), ApiKey.expires_at > now),
|
||||
or_(col(ApiKey.expires_at).is_(None), col(ApiKey.expires_at) > now),
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -638,7 +638,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.key_id == key_id)
|
||||
.where(col(ApiKey.key_id) == key_id)
|
||||
.values(last_used_at=datetime.now(timezone.utc)),
|
||||
)
|
||||
|
||||
@@ -649,7 +649,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
async with session.begin():
|
||||
query = (
|
||||
update(ApiKey)
|
||||
.where(ApiKey.key_id == key_id)
|
||||
.where(col(ApiKey.key_id) == key_id)
|
||||
.values(revoked_at=datetime.now(timezone.utc))
|
||||
)
|
||||
result = T.cast(CursorResult, await session.execute(query))
|
||||
@@ -663,7 +663,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
result = T.cast(
|
||||
CursorResult,
|
||||
await session.execute(
|
||||
delete(ApiKey).where(ApiKey.key_id == key_id)
|
||||
delete(ApiKey).where(col(ApiKey.key_id) == key_id)
|
||||
),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
@@ -1457,7 +1457,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
return query
|
||||
|
||||
@staticmethod
|
||||
def _rows_to_session_dicts(rows: list[tuple]) -> list[dict]:
|
||||
def _rows_to_session_dicts(rows: T.Sequence[Row[tuple]]) -> list[dict]:
|
||||
sessions_with_projects = []
|
||||
for row in rows:
|
||||
platform_session = row[0]
|
||||
|
||||
@@ -256,6 +256,46 @@ class KBSQLiteDatabase:
|
||||
"knowledge_base": row[1],
|
||||
}
|
||||
|
||||
async def get_documents_with_metadata_batch(
|
||||
self, doc_ids: set[str]
|
||||
) -> dict[str, dict]:
|
||||
"""批量获取文档及其所属知识库元数据
|
||||
|
||||
Args:
|
||||
doc_ids: 文档 ID 集合
|
||||
|
||||
Returns:
|
||||
dict: doc_id -> {"document": KBDocument, "knowledge_base": KnowledgeBase}
|
||||
|
||||
"""
|
||||
if not doc_ids:
|
||||
return {}
|
||||
|
||||
metadata_map: dict[str, dict] = {}
|
||||
# SQLite 参数上限为 999,分片查询避免超限
|
||||
chunk_size = 900
|
||||
doc_id_list = list(doc_ids)
|
||||
|
||||
async with self.get_db() as session:
|
||||
for i in range(0, len(doc_id_list), chunk_size):
|
||||
chunk = doc_id_list[i : i + chunk_size]
|
||||
stmt = (
|
||||
select(KBDocument, KnowledgeBase)
|
||||
.join(
|
||||
KnowledgeBase,
|
||||
col(KBDocument.kb_id) == col(KnowledgeBase.kb_id),
|
||||
)
|
||||
.where(col(KBDocument.doc_id).in_(chunk))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
for row in result.all():
|
||||
metadata_map[row[0].doc_id] = {
|
||||
"document": row[0],
|
||||
"knowledge_base": row[1],
|
||||
}
|
||||
|
||||
return metadata_map
|
||||
|
||||
async def delete_document_by_id(self, doc_id: str, vec_db: FaissVecDB) -> None:
|
||||
"""删除单个文档及其相关数据"""
|
||||
# 在知识库表中删除
|
||||
|
||||
@@ -142,10 +142,13 @@ class RetrievalManager:
|
||||
f"Rank fusion took {time_end - time_start:.2f}s and returned {len(fused_results)} results.",
|
||||
)
|
||||
|
||||
# 4. 转换为 RetrievalResult (获取元数据)
|
||||
# 4. 转换为 RetrievalResult (批量获取元数据)
|
||||
doc_ids = {fr.doc_id for fr in fused_results}
|
||||
metadata_map = await self.kb_db.get_documents_with_metadata_batch(doc_ids)
|
||||
|
||||
retrieval_results = []
|
||||
for fr in fused_results:
|
||||
metadata_dict = await self.kb_db.get_document_with_metadata(fr.doc_id)
|
||||
metadata_dict = metadata_map.get(fr.doc_id)
|
||||
if metadata_dict:
|
||||
retrieval_results.append(
|
||||
RetrievalResult(
|
||||
|
||||
@@ -720,13 +720,38 @@ class File(BaseMessageComponent):
|
||||
if allow_return_url and self.url:
|
||||
return self.url
|
||||
|
||||
if self.file_ and os.path.exists(self.file_):
|
||||
return os.path.abspath(self.file_)
|
||||
if self.file_:
|
||||
path = self.file_
|
||||
if path.startswith("file://"):
|
||||
# 处理 file:// (2 slashes) 或 file:/// (3 slashes)
|
||||
# pathlib.as_uri() 通常生成 file:///
|
||||
path = path[7:]
|
||||
# 兼容 Windows: file:///C:/path -> /C:/path -> C:/path
|
||||
if (
|
||||
os.name == "nt"
|
||||
and len(path) > 2
|
||||
and path[0] == "/"
|
||||
and path[2] == ":"
|
||||
):
|
||||
path = path[1:]
|
||||
|
||||
if os.path.exists(path):
|
||||
return os.path.abspath(path)
|
||||
|
||||
if self.url:
|
||||
await self._download_file()
|
||||
if self.file_:
|
||||
return os.path.abspath(self.file_)
|
||||
path = self.file_
|
||||
if path.startswith("file://"):
|
||||
path = path[7:]
|
||||
if (
|
||||
os.name == "nt"
|
||||
and len(path) > 2
|
||||
and path[0] == "/"
|
||||
and path[2] == ":"
|
||||
):
|
||||
path = path[1:]
|
||||
return os.path.abspath(path)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from astrbot import logger
|
||||
from astrbot.api import sp
|
||||
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import Persona, PersonaFolder, Personality
|
||||
@@ -58,6 +59,60 @@ class PersonaManager:
|
||||
except Exception:
|
||||
return DEFAULT_PERSONALITY
|
||||
|
||||
async def resolve_selected_persona(
|
||||
self,
|
||||
*,
|
||||
umo: str | MessageSession,
|
||||
conversation_persona_id: str | None,
|
||||
platform_name: str,
|
||||
provider_settings: dict | None = None,
|
||||
) -> tuple[str | None, Personality | None, str | None, bool]:
|
||||
"""解析当前会话最终生效的人格。
|
||||
|
||||
Returns:
|
||||
tuple:
|
||||
- selected persona_id
|
||||
- selected persona object
|
||||
- force applied persona_id from session rule
|
||||
- whether use webchat special default persona
|
||||
"""
|
||||
session_service_config = (
|
||||
await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=str(umo),
|
||||
key="session_service_config",
|
||||
default={},
|
||||
)
|
||||
or {}
|
||||
)
|
||||
|
||||
force_applied_persona_id = session_service_config.get("persona_id")
|
||||
persona_id = force_applied_persona_id
|
||||
|
||||
if not persona_id:
|
||||
persona_id = conversation_persona_id
|
||||
if persona_id == "[%None]":
|
||||
pass
|
||||
elif persona_id is None:
|
||||
persona_id = (provider_settings or {}).get("default_personality")
|
||||
|
||||
persona = next(
|
||||
(item for item in self.personas_v3 if item["name"] == persona_id),
|
||||
None,
|
||||
)
|
||||
|
||||
use_webchat_special_default = False
|
||||
if not persona and platform_name == "webchat" and persona_id != "[%None]":
|
||||
persona_id = "_chatui_default_"
|
||||
use_webchat_special_default = True
|
||||
|
||||
return (
|
||||
persona_id,
|
||||
persona,
|
||||
force_applied_persona_id,
|
||||
use_webchat_special_default,
|
||||
)
|
||||
|
||||
async def delete_persona(self, persona_id: str) -> None:
|
||||
"""删除指定 persona"""
|
||||
if not await self.db.get_persona_by_id(persona_id):
|
||||
|
||||
@@ -1,30 +1,71 @@
|
||||
"""Pipeline package exports.
|
||||
|
||||
This module intentionally avoids eager imports of all pipeline stage modules to
|
||||
prevent import-time cycles. Stage classes remain available via lazy attribute
|
||||
resolution for backward compatibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from astrbot.core.message.message_event_result import (
|
||||
EventResultType,
|
||||
MessageEventResult,
|
||||
)
|
||||
|
||||
from .content_safety_check.stage import ContentSafetyCheckStage
|
||||
from .preprocess_stage.stage import PreProcessStage
|
||||
from .process_stage.stage import ProcessStage
|
||||
from .rate_limit_check.stage import RateLimitStage
|
||||
from .respond.stage import RespondStage
|
||||
from .result_decorate.stage import ResultDecorateStage
|
||||
from .session_status_check.stage import SessionStatusCheckStage
|
||||
from .waking_check.stage import WakingCheckStage
|
||||
from .whitelist_check.stage import WhitelistCheckStage
|
||||
from .stage_order import STAGES_ORDER
|
||||
|
||||
# 管道阶段顺序
|
||||
STAGES_ORDER = [
|
||||
"WakingCheckStage", # 检查是否需要唤醒
|
||||
"WhitelistCheckStage", # 检查是否在群聊/私聊白名单
|
||||
"SessionStatusCheckStage", # 检查会话是否整体启用
|
||||
"RateLimitStage", # 检查会话是否超过频率限制
|
||||
"ContentSafetyCheckStage", # 检查内容安全
|
||||
"PreProcessStage", # 预处理
|
||||
"ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用
|
||||
"ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等
|
||||
"RespondStage", # 发送消息
|
||||
]
|
||||
if TYPE_CHECKING:
|
||||
from .content_safety_check.stage import ContentSafetyCheckStage
|
||||
from .preprocess_stage.stage import PreProcessStage
|
||||
from .process_stage.stage import ProcessStage
|
||||
from .rate_limit_check.stage import RateLimitStage
|
||||
from .respond.stage import RespondStage
|
||||
from .result_decorate.stage import ResultDecorateStage
|
||||
from .session_status_check.stage import SessionStatusCheckStage
|
||||
from .waking_check.stage import WakingCheckStage
|
||||
from .whitelist_check.stage import WhitelistCheckStage
|
||||
|
||||
_LAZY_EXPORTS = {
|
||||
"ContentSafetyCheckStage": (
|
||||
"astrbot.core.pipeline.content_safety_check.stage",
|
||||
"ContentSafetyCheckStage",
|
||||
),
|
||||
"PreProcessStage": (
|
||||
"astrbot.core.pipeline.preprocess_stage.stage",
|
||||
"PreProcessStage",
|
||||
),
|
||||
"ProcessStage": (
|
||||
"astrbot.core.pipeline.process_stage.stage",
|
||||
"ProcessStage",
|
||||
),
|
||||
"RateLimitStage": (
|
||||
"astrbot.core.pipeline.rate_limit_check.stage",
|
||||
"RateLimitStage",
|
||||
),
|
||||
"RespondStage": (
|
||||
"astrbot.core.pipeline.respond.stage",
|
||||
"RespondStage",
|
||||
),
|
||||
"ResultDecorateStage": (
|
||||
"astrbot.core.pipeline.result_decorate.stage",
|
||||
"ResultDecorateStage",
|
||||
),
|
||||
"SessionStatusCheckStage": (
|
||||
"astrbot.core.pipeline.session_status_check.stage",
|
||||
"SessionStatusCheckStage",
|
||||
),
|
||||
"WakingCheckStage": (
|
||||
"astrbot.core.pipeline.waking_check.stage",
|
||||
"WakingCheckStage",
|
||||
),
|
||||
"WhitelistCheckStage": (
|
||||
"astrbot.core.pipeline.whitelist_check.stage",
|
||||
"WhitelistCheckStage",
|
||||
),
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"ContentSafetyCheckStage",
|
||||
@@ -36,6 +77,21 @@ __all__ = [
|
||||
"RespondStage",
|
||||
"ResultDecorateStage",
|
||||
"SessionStatusCheckStage",
|
||||
"STAGES_ORDER",
|
||||
"WakingCheckStage",
|
||||
"WhitelistCheckStage",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name not in _LAZY_EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module_path, attr_name = _LAZY_EXPORTS[name]
|
||||
module = import_module(module_path)
|
||||
value = getattr(module, attr_name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Pipeline bootstrap utilities."""
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from .stage import registered_stages
|
||||
|
||||
_BUILTIN_STAGE_MODULES = (
|
||||
"astrbot.core.pipeline.waking_check.stage",
|
||||
"astrbot.core.pipeline.whitelist_check.stage",
|
||||
"astrbot.core.pipeline.session_status_check.stage",
|
||||
"astrbot.core.pipeline.rate_limit_check.stage",
|
||||
"astrbot.core.pipeline.content_safety_check.stage",
|
||||
"astrbot.core.pipeline.preprocess_stage.stage",
|
||||
"astrbot.core.pipeline.process_stage.stage",
|
||||
"astrbot.core.pipeline.result_decorate.stage",
|
||||
"astrbot.core.pipeline.respond.stage",
|
||||
)
|
||||
|
||||
_EXPECTED_STAGE_NAMES = {
|
||||
"WakingCheckStage",
|
||||
"WhitelistCheckStage",
|
||||
"SessionStatusCheckStage",
|
||||
"RateLimitStage",
|
||||
"ContentSafetyCheckStage",
|
||||
"PreProcessStage",
|
||||
"ProcessStage",
|
||||
"ResultDecorateStage",
|
||||
"RespondStage",
|
||||
}
|
||||
|
||||
_builtin_stages_registered = False
|
||||
|
||||
|
||||
def ensure_builtin_stages_registered() -> None:
|
||||
"""Ensure built-in pipeline stages are imported and registered."""
|
||||
global _builtin_stages_registered
|
||||
|
||||
if _builtin_stages_registered:
|
||||
return
|
||||
|
||||
stage_names = {stage_cls.__name__ for stage_cls in registered_stages}
|
||||
if _EXPECTED_STAGE_NAMES.issubset(stage_names):
|
||||
_builtin_stages_registered = True
|
||||
return
|
||||
|
||||
for module_path in _BUILTIN_STAGE_MODULES:
|
||||
import_module(module_path)
|
||||
|
||||
_builtin_stages_registered = True
|
||||
|
||||
|
||||
__all__ = ["ensure_builtin_stages_registered"]
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from astrbot.core.config import AstrBotConfig
|
||||
from astrbot.core.star import PluginManager
|
||||
|
||||
from .context_utils import call_event_hook, call_handler
|
||||
|
||||
@@ -11,7 +13,7 @@ class PipelineContext:
|
||||
"""上下文对象,包含管道执行所需的上下文信息"""
|
||||
|
||||
astrbot_config: AstrBotConfig # AstrBot 配置对象
|
||||
plugin_manager: PluginManager # 插件管理器对象
|
||||
plugin_manager: Any # 插件管理器对象
|
||||
astrbot_config_id: str
|
||||
call_handler = call_handler
|
||||
call_event_hook = call_event_hook
|
||||
|
||||
@@ -19,6 +19,7 @@ from astrbot.core.message.message_event_result import (
|
||||
MessageEventResult,
|
||||
ResultContentType,
|
||||
)
|
||||
from astrbot.core.pipeline.stage import Stage
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.provider.entities import (
|
||||
LLMResponse,
|
||||
@@ -30,7 +31,6 @@ from astrbot.core.utils.session_lock import session_lock_manager
|
||||
|
||||
from .....astr_agent_run_util import run_agent, run_live_agent
|
||||
from ....context import PipelineContext, call_event_hook
|
||||
from ...stage import Stage
|
||||
|
||||
|
||||
class InternalAgentSubStage(Stage):
|
||||
@@ -54,6 +54,7 @@ class InternalAgentSubStage(Stage):
|
||||
if isinstance(self.max_step, bool): # workaround: #2622
|
||||
self.max_step = 30
|
||||
self.show_tool_use: bool = settings.get("show_tool_use_status", True)
|
||||
self.show_tool_call_result: bool = settings.get("show_tool_call_result", False)
|
||||
self.show_reasoning = settings.get("display_reasoning_text", False)
|
||||
self.sanitize_context_by_modalities: bool = settings.get(
|
||||
"sanitize_context_by_modalities",
|
||||
@@ -240,6 +241,7 @@ class InternalAgentSubStage(Stage):
|
||||
tts_provider,
|
||||
self.max_step,
|
||||
self.show_tool_use,
|
||||
self.show_tool_call_result,
|
||||
show_reasoning=self.show_reasoning,
|
||||
),
|
||||
),
|
||||
@@ -269,6 +271,7 @@ class InternalAgentSubStage(Stage):
|
||||
agent_runner,
|
||||
self.max_step,
|
||||
self.show_tool_use,
|
||||
self.show_tool_call_result,
|
||||
show_reasoning=self.show_reasoning,
|
||||
),
|
||||
),
|
||||
@@ -297,6 +300,7 @@ class InternalAgentSubStage(Stage):
|
||||
agent_runner,
|
||||
self.max_step,
|
||||
self.show_tool_use,
|
||||
self.show_tool_call_result,
|
||||
stream_to_general,
|
||||
show_reasoning=self.show_reasoning,
|
||||
):
|
||||
|
||||
@@ -8,6 +8,7 @@ from astrbot.core.agent.runners.dashscope.dashscope_agent_runner import (
|
||||
DashscopeAgentRunner,
|
||||
)
|
||||
from astrbot.core.agent.runners.dify.dify_agent_runner import DifyAgentRunner
|
||||
from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
|
||||
from astrbot.core.message.components import Image
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageChain,
|
||||
@@ -17,6 +18,7 @@ from astrbot.core.message.message_event_result import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.agent.runners.base import BaseAgentRunner
|
||||
from astrbot.core.pipeline.stage import Stage
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.provider.entities import (
|
||||
ProviderRequest,
|
||||
@@ -25,9 +27,7 @@ from astrbot.core.star.star_handler import EventType
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
|
||||
from .....astr_agent_context import AgentContextWrapper, AstrAgentContext
|
||||
from .....astr_agent_hooks import MAIN_AGENT_HOOKS
|
||||
from ....context import PipelineContext, call_event_hook
|
||||
from ...stage import Stage
|
||||
|
||||
AGENT_RUNNER_TYPE_KEY = {
|
||||
"dify": "dify_agent_runner_provider_id",
|
||||
|
||||
@@ -8,15 +8,17 @@ from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import (
|
||||
)
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
|
||||
from . import STAGES_ORDER
|
||||
from .bootstrap import ensure_builtin_stages_registered
|
||||
from .context import PipelineContext
|
||||
from .stage import registered_stages
|
||||
from .stage_order import STAGES_ORDER
|
||||
|
||||
|
||||
class PipelineScheduler:
|
||||
"""管道调度器,负责调度各个阶段的执行"""
|
||||
|
||||
def __init__(self, context: PipelineContext) -> None:
|
||||
ensure_builtin_stages_registered()
|
||||
registered_stages.sort(
|
||||
key=lambda x: STAGES_ORDER.index(x.__name__),
|
||||
) # 按照顺序排序
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Pipeline stage execution order."""
|
||||
|
||||
STAGES_ORDER = [
|
||||
"WakingCheckStage", # 检查是否需要唤醒
|
||||
"WhitelistCheckStage", # 检查是否在群聊/私聊白名单
|
||||
"SessionStatusCheckStage", # 检查会话是否整体启用
|
||||
"RateLimitStage", # 检查会话是否超过频率限制
|
||||
"ContentSafetyCheckStage", # 检查内容安全
|
||||
"PreProcessStage", # 预处理
|
||||
"ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用
|
||||
"ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等
|
||||
"RespondStage", # 发送消息
|
||||
]
|
||||
|
||||
__all__ = ["STAGES_ORDER"]
|
||||
@@ -52,9 +52,19 @@ class AstrMessageEvent(abc.ABC):
|
||||
self.is_at_or_wake_command = False
|
||||
"""是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)"""
|
||||
self._extras: dict[str, Any] = {}
|
||||
message_type = getattr(message_obj, "type", None)
|
||||
if not isinstance(message_type, MessageType):
|
||||
try:
|
||||
message_type = MessageType(str(message_type))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
logger.warning(
|
||||
f"Failed to convert message type {message_obj.type!r} to MessageType. "
|
||||
f"Falling back to FRIEND_MESSAGE."
|
||||
)
|
||||
message_type = MessageType.FRIEND_MESSAGE
|
||||
self.session = MessageSession(
|
||||
platform_name=platform_meta.id,
|
||||
message_type=message_obj.type,
|
||||
message_type=message_type,
|
||||
session_id=session_id,
|
||||
)
|
||||
# self.unified_msg_origin = str(self.session)
|
||||
@@ -159,15 +169,18 @@ class AstrMessageEvent(abc.ABC):
|
||||
|
||||
除了文本消息外,其他消息类型会被转换为对应的占位符。如图片消息会被转换为 [图片]。
|
||||
"""
|
||||
return self._outline_chain(self.message_obj.message)
|
||||
return self._outline_chain(getattr(self.message_obj, "message", None))
|
||||
|
||||
def get_messages(self) -> list[BaseMessageComponent]:
|
||||
"""获取消息链。"""
|
||||
return self.message_obj.message
|
||||
return getattr(self.message_obj, "message", [])
|
||||
|
||||
def get_message_type(self) -> MessageType:
|
||||
"""获取消息类型。"""
|
||||
return self.message_obj.type
|
||||
message_type = getattr(self.message_obj, "type", None)
|
||||
if isinstance(message_type, MessageType):
|
||||
return message_type
|
||||
return self.session.message_type
|
||||
|
||||
def get_session_id(self) -> str:
|
||||
"""获取会话id。"""
|
||||
@@ -175,21 +188,30 @@ class AstrMessageEvent(abc.ABC):
|
||||
|
||||
def get_group_id(self) -> str:
|
||||
"""获取群组id。如果不是群组消息,返回空字符串。"""
|
||||
return self.message_obj.group_id
|
||||
return getattr(self.message_obj, "group_id", "")
|
||||
|
||||
def get_self_id(self) -> str:
|
||||
"""获取机器人自身的id。"""
|
||||
return self.message_obj.self_id
|
||||
return getattr(self.message_obj, "self_id", "")
|
||||
|
||||
def get_sender_id(self) -> str:
|
||||
"""获取消息发送者的id。"""
|
||||
return self.message_obj.sender.user_id
|
||||
sender = getattr(self.message_obj, "sender", None)
|
||||
if sender and isinstance(getattr(sender, "user_id", None), str):
|
||||
return sender.user_id
|
||||
return ""
|
||||
|
||||
def get_sender_name(self) -> str:
|
||||
"""获取消息发送者的名称。(可能会返回空字符串)"""
|
||||
if isinstance(self.message_obj.sender.nickname, str):
|
||||
return self.message_obj.sender.nickname
|
||||
return ""
|
||||
sender = getattr(self.message_obj, "sender", None)
|
||||
if not sender:
|
||||
return ""
|
||||
nickname = getattr(sender, "nickname", None)
|
||||
if nickname is None:
|
||||
return ""
|
||||
if isinstance(nickname, str):
|
||||
return nickname
|
||||
return str(nickname)
|
||||
|
||||
def set_extra(self, key, value) -> None:
|
||||
"""设置额外的信息。"""
|
||||
@@ -208,7 +230,7 @@ class AstrMessageEvent(abc.ABC):
|
||||
|
||||
def is_private_chat(self) -> bool:
|
||||
"""是否是私聊。"""
|
||||
return self.message_obj.type.value == (MessageType.FRIEND_MESSAGE).value
|
||||
return self.get_message_type() == MessageType.FRIEND_MESSAGE
|
||||
|
||||
def is_wake_up(self) -> bool:
|
||||
"""是否是唤醒机器人的事件。"""
|
||||
|
||||
@@ -45,6 +45,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
if isinstance(segment, File):
|
||||
# For File segments, we need to handle the file differently
|
||||
d = await segment.to_dict()
|
||||
file_val = d.get("data", {}).get("file", "")
|
||||
if file_val:
|
||||
import pathlib
|
||||
|
||||
try:
|
||||
# 使用 pathlib 处理路径,能更好地处理 Windows/Linux 差异
|
||||
path_obj = pathlib.Path(file_val)
|
||||
# 如果是绝对路径且不包含协议头 (://),则转换为标准的 file: URI
|
||||
if path_obj.is_absolute() and "://" not in file_val:
|
||||
d["data"]["file"] = path_obj.as_uri()
|
||||
except Exception:
|
||||
# 如果不是合法路径(例如已经是特定的特殊字符串),则跳过转换
|
||||
pass
|
||||
return d
|
||||
if isinstance(segment, Video):
|
||||
d = await segment.to_dict()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import itertools
|
||||
import logging
|
||||
import time
|
||||
@@ -436,7 +437,42 @@ class AiocqhttpAdapter(Platform):
|
||||
return coro
|
||||
|
||||
async def terminate(self) -> None:
|
||||
self.shutdown_event.set()
|
||||
if hasattr(self, "shutdown_event"):
|
||||
self.shutdown_event.set()
|
||||
await self._close_reverse_ws_connections()
|
||||
|
||||
async def _close_reverse_ws_connections(self) -> None:
|
||||
api_clients = getattr(self.bot, "_wsr_api_clients", None)
|
||||
event_clients = getattr(self.bot, "_wsr_event_clients", None)
|
||||
|
||||
ws_clients: set[Any] = set()
|
||||
if isinstance(api_clients, dict):
|
||||
ws_clients.update(api_clients.values())
|
||||
if isinstance(event_clients, set):
|
||||
ws_clients.update(event_clients)
|
||||
|
||||
close_tasks: list[Awaitable[Any]] = []
|
||||
for ws in ws_clients:
|
||||
close_func = getattr(ws, "close", None)
|
||||
if not callable(close_func):
|
||||
continue
|
||||
try:
|
||||
close_result = close_func(code=1000, reason="Adapter shutdown")
|
||||
except TypeError:
|
||||
close_result = close_func()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if inspect.isawaitable(close_result):
|
||||
close_tasks.append(close_result)
|
||||
|
||||
if close_tasks:
|
||||
await asyncio.gather(*close_tasks, return_exceptions=True)
|
||||
|
||||
if isinstance(api_clients, dict):
|
||||
api_clients.clear()
|
||||
if isinstance(event_clients, set):
|
||||
event_clients.clear()
|
||||
|
||||
async def shutdown_trigger_placeholder(self) -> None:
|
||||
await self.shutdown_event.wait()
|
||||
|
||||
@@ -65,15 +65,6 @@ LINE_I18N_RESOURCES = {
|
||||
"line",
|
||||
"LINE Messaging API 适配器",
|
||||
support_streaming_message=False,
|
||||
default_config_tmpl={
|
||||
"id": "line",
|
||||
"type": "line",
|
||||
"enable": False,
|
||||
"channel_access_token": "",
|
||||
"channel_secret": "",
|
||||
"unified_webhook_mode": True,
|
||||
"webhook_uuid": "",
|
||||
},
|
||||
config_metadata=LINE_CONFIG_METADATA,
|
||||
i18n_resources=LINE_I18N_RESOURCES,
|
||||
)
|
||||
|
||||
@@ -162,6 +162,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if record_file_path: # group record msg
|
||||
media = await self.upload_group_and_c2c_record(
|
||||
record_file_path,
|
||||
@@ -170,6 +172,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_group_message(
|
||||
group_openid=source.group_openid, # type: ignore
|
||||
@@ -188,6 +192,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if record_file_path: # c2c record
|
||||
media = await self.upload_group_and_c2c_record(
|
||||
record_file_path,
|
||||
@@ -196,6 +202,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if stream:
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.post_c2c_message(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from typing import cast
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from telegram import BotCommand, Update
|
||||
@@ -25,6 +27,9 @@ from astrbot.core.star.filter.command import CommandFilter
|
||||
from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.star_handler import star_handlers_registry
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
from astrbot.core.utils.media_utils import convert_audio_to_wav
|
||||
|
||||
from .tg_event import TelegramPlatformEvent
|
||||
|
||||
@@ -375,8 +380,19 @@ class TelegramPlatformAdapter(Platform):
|
||||
|
||||
elif update.message.voice:
|
||||
file = await update.message.voice.get_file()
|
||||
|
||||
file_basename = os.path.basename(cast(str, file.file_path))
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
temp_path = os.path.join(temp_dir, file_basename)
|
||||
await download_file(cast(str, file.file_path), path=temp_path)
|
||||
path_wav = os.path.join(
|
||||
temp_dir,
|
||||
f"{file_basename}.wav",
|
||||
)
|
||||
path_wav = await convert_audio_to_wav(temp_path, path_wav)
|
||||
|
||||
message.message = [
|
||||
Comp.Record(file=file.file_path, url=file.file_path),
|
||||
Comp.Record(file=path_wav, url=path_wav),
|
||||
]
|
||||
|
||||
elif update.message.photo:
|
||||
|
||||
@@ -18,6 +18,7 @@ from astrbot.api.message_components import (
|
||||
Plain,
|
||||
Record,
|
||||
Reply,
|
||||
Video,
|
||||
)
|
||||
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
|
||||
|
||||
@@ -36,6 +37,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
# 消息类型到 chat action 的映射,用于优先级判断
|
||||
ACTION_BY_TYPE: dict[type, str] = {
|
||||
Record: ChatAction.UPLOAD_VOICE,
|
||||
Video: ChatAction.UPLOAD_VIDEO,
|
||||
File: ChatAction.UPLOAD_DOCUMENT,
|
||||
Image: ChatAction.UPLOAD_PHOTO,
|
||||
Plain: ChatAction.TYPING,
|
||||
@@ -114,10 +116,18 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
**payload: Any,
|
||||
) -> None:
|
||||
"""发送媒体时显示 upload action,发送完成后恢复 typing"""
|
||||
await cls._send_chat_action(client, user_name, upload_action, message_thread_id)
|
||||
await send_coro(**payload)
|
||||
effective_thread_id = message_thread_id or cast(
|
||||
str | None, payload.get("message_thread_id")
|
||||
)
|
||||
await cls._send_chat_action(
|
||||
client, user_name, ChatAction.TYPING, message_thread_id
|
||||
client, user_name, upload_action, effective_thread_id
|
||||
)
|
||||
send_payload = dict(payload)
|
||||
if effective_thread_id and "message_thread_id" not in send_payload:
|
||||
send_payload["message_thread_id"] = effective_thread_id
|
||||
await send_coro(**send_payload)
|
||||
await cls._send_chat_action(
|
||||
client, user_name, ChatAction.TYPING, effective_thread_id
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -141,14 +151,16 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
"""
|
||||
try:
|
||||
if use_media_action:
|
||||
media_payload = dict(payload)
|
||||
if message_thread_id and "message_thread_id" not in media_payload:
|
||||
media_payload["message_thread_id"] = message_thread_id
|
||||
await cls._send_media_with_action(
|
||||
client,
|
||||
ChatAction.UPLOAD_VOICE,
|
||||
client.send_voice,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
voice=path,
|
||||
**cast(Any, payload),
|
||||
**cast(Any, media_payload),
|
||||
)
|
||||
else:
|
||||
await client.send_voice(voice=path, **cast(Any, payload))
|
||||
@@ -162,15 +174,17 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
"To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'."
|
||||
)
|
||||
if use_media_action:
|
||||
media_payload = dict(payload)
|
||||
if message_thread_id and "message_thread_id" not in media_payload:
|
||||
media_payload["message_thread_id"] = message_thread_id
|
||||
await cls._send_media_with_action(
|
||||
client,
|
||||
ChatAction.UPLOAD_DOCUMENT,
|
||||
client.send_document,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
document=path,
|
||||
caption=caption,
|
||||
**cast(Any, payload),
|
||||
**cast(Any, media_payload),
|
||||
)
|
||||
else:
|
||||
await client.send_document(
|
||||
@@ -278,6 +292,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
caption=i.text or None,
|
||||
use_media_action=False,
|
||||
)
|
||||
elif isinstance(i, Video):
|
||||
path = await i.convert_to_file_path()
|
||||
await client.send_video(
|
||||
video=path,
|
||||
caption=getattr(i, "text", None) or None,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
|
||||
async def send(self, message: MessageChain) -> None:
|
||||
if self.get_message_type() == MessageType.GROUP_MESSAGE:
|
||||
@@ -333,7 +354,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
"chat_id": user_name,
|
||||
}
|
||||
if message_thread_id:
|
||||
payload["reply_to_message_id"] = message_thread_id
|
||||
payload["message_thread_id"] = message_thread_id
|
||||
|
||||
delta = ""
|
||||
current_content = ""
|
||||
@@ -375,7 +396,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
ChatAction.UPLOAD_PHOTO,
|
||||
self.client.send_photo,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
photo=image_path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
@@ -388,7 +408,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
ChatAction.UPLOAD_DOCUMENT,
|
||||
self.client.send_document,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
document=path,
|
||||
filename=name,
|
||||
**cast(Any, payload),
|
||||
@@ -406,6 +425,17 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
use_media_action=True,
|
||||
)
|
||||
continue
|
||||
elif isinstance(i, Video):
|
||||
path = await i.convert_to_file_path()
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_VIDEO,
|
||||
self.client.send_video,
|
||||
user_name=user_name,
|
||||
video=path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"不支持的消息类型: {type(i)}")
|
||||
continue
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, cast
|
||||
|
||||
import quart
|
||||
@@ -65,7 +65,9 @@ class WeixinOfficialAccountServer:
|
||||
|
||||
self.event_queue = event_queue
|
||||
|
||||
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
|
||||
self.callback: (
|
||||
Callable[[BaseMessage], Coroutine[Any, Any, str | None]] | None
|
||||
) = None
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复
|
||||
|
||||
@@ -48,6 +48,9 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
|
||||
result = await self.client.models.embed_content(
|
||||
model=self.model,
|
||||
contents=text,
|
||||
config=types.EmbedContentConfig(
|
||||
output_dimensionality=self.get_dim(),
|
||||
),
|
||||
)
|
||||
assert result.embeddings is not None
|
||||
assert result.embeddings[0].values is not None
|
||||
@@ -61,6 +64,9 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
|
||||
result = await self.client.models.embed_content(
|
||||
model=self.model,
|
||||
contents=cast(types.ContentListUnion, text),
|
||||
config=types.EmbedContentConfig(
|
||||
output_dimensionality=self.get_dim(),
|
||||
),
|
||||
)
|
||||
assert result.embeddings is not None
|
||||
|
||||
|
||||
@@ -23,12 +23,16 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
if proxy:
|
||||
logger.info(f"[OpenAI Embedding] 使用代理: {proxy}")
|
||||
http_client = httpx.AsyncClient(proxy=proxy)
|
||||
api_base = provider_config.get("embedding_api_base", "").strip()
|
||||
if not api_base:
|
||||
api_base = "https://api.openai.com/v1"
|
||||
else:
|
||||
api_base = api_base.removesuffix("/")
|
||||
if not api_base.endswith("/v1"):
|
||||
api_base = f"{api_base}/v1"
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=provider_config.get("embedding_api_key"),
|
||||
base_url=provider_config.get(
|
||||
"embedding_api_base",
|
||||
"https://api.openai.com/v1",
|
||||
),
|
||||
base_url=api_base,
|
||||
timeout=int(provider_config.get("timeout", 20)),
|
||||
http_client=http_client,
|
||||
)
|
||||
@@ -36,12 +40,20 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
|
||||
async def get_embedding(self, text: str) -> list[float]:
|
||||
"""获取文本的嵌入"""
|
||||
embedding = await self.client.embeddings.create(input=text, model=self.model)
|
||||
embedding = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.model,
|
||||
dimensions=self.get_dim(),
|
||||
)
|
||||
return embedding.data[0].embedding
|
||||
|
||||
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
|
||||
"""批量获取文本的嵌入"""
|
||||
embeddings = await self.client.embeddings.create(input=text, model=self.model)
|
||||
embeddings = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.model,
|
||||
dimensions=self.get_dim(),
|
||||
)
|
||||
return [item.embedding for item in embeddings.data]
|
||||
|
||||
def get_dim(self) -> int:
|
||||
|
||||
@@ -1,68 +1,19 @@
|
||||
from astrbot.core import html_renderer
|
||||
# 兼容导出: Provider 从 provider 模块重新导出
|
||||
from astrbot.core.provider import Provider
|
||||
from astrbot.core.star.star_tools import StarTools
|
||||
from astrbot.core.utils.command_parser import CommandParserMixin
|
||||
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
|
||||
|
||||
from .base import Star
|
||||
from .context import Context
|
||||
from .star import StarMetadata, star_map, star_registry
|
||||
from .star_manager import PluginManager
|
||||
from .star_tools import StarTools
|
||||
|
||||
|
||||
class Star(CommandParserMixin, PluginKVStoreMixin):
|
||||
"""所有插件(Star)的父类,所有插件都应该继承于这个类"""
|
||||
|
||||
author: str
|
||||
name: str
|
||||
|
||||
def __init__(self, context: Context, config: dict | None = None) -> None:
|
||||
StarTools.initialize(context)
|
||||
self.context = context
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
if not star_map.get(cls.__module__):
|
||||
metadata = StarMetadata(
|
||||
star_cls_type=cls,
|
||||
module_path=cls.__module__,
|
||||
)
|
||||
star_map[cls.__module__] = metadata
|
||||
star_registry.append(metadata)
|
||||
else:
|
||||
star_map[cls.__module__].star_cls_type = cls
|
||||
star_map[cls.__module__].module_path = cls.__module__
|
||||
|
||||
async def text_to_image(self, text: str, return_url=True) -> str:
|
||||
"""将文本转换为图片"""
|
||||
return await html_renderer.render_t2i(
|
||||
text,
|
||||
return_url=return_url,
|
||||
template_name=self.context._config.get("t2i_active_template"),
|
||||
)
|
||||
|
||||
async def html_render(
|
||||
self,
|
||||
tmpl: str,
|
||||
data: dict,
|
||||
return_url=True,
|
||||
options: dict | None = None,
|
||||
) -> str:
|
||||
"""渲染 HTML"""
|
||||
return await html_renderer.render_custom_template(
|
||||
tmpl,
|
||||
data,
|
||||
return_url=return_url,
|
||||
options=options,
|
||||
)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""当插件被激活时会调用这个方法"""
|
||||
|
||||
async def terminate(self) -> None:
|
||||
"""当插件被禁用、重载插件时会调用这个方法"""
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""[Deprecated] 当插件被禁用、重载插件时会调用这个方法"""
|
||||
|
||||
|
||||
__all__ = ["Context", "PluginManager", "Provider", "Star", "StarMetadata", "StarTools"]
|
||||
__all__ = [
|
||||
"Context",
|
||||
"PluginManager",
|
||||
"Provider",
|
||||
"Star",
|
||||
"StarMetadata",
|
||||
"StarTools",
|
||||
"star_map",
|
||||
"star_registry",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Protocol
|
||||
|
||||
from astrbot.core import html_renderer
|
||||
from astrbot.core.utils.command_parser import CommandParserMixin
|
||||
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
|
||||
|
||||
from .star import StarMetadata, star_map, star_registry
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
class Star(CommandParserMixin, PluginKVStoreMixin):
|
||||
"""所有插件(Star)的父类,所有插件都应该继承于这个类"""
|
||||
|
||||
author: str
|
||||
name: str
|
||||
|
||||
class _ContextLike(Protocol):
|
||||
def get_config(self, umo: str | None = None) -> Any: ...
|
||||
|
||||
def __init__(self, context: _ContextLike, config: dict | None = None) -> None:
|
||||
self.context = context
|
||||
|
||||
def _get_context_config(self) -> Any:
|
||||
get_config = getattr(self.context, "get_config", None)
|
||||
if callable(get_config):
|
||||
try:
|
||||
return get_config()
|
||||
except Exception as e:
|
||||
logger.debug(f"get_config() failed: {e}")
|
||||
return None
|
||||
return getattr(self.context, "_config", None)
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
if not star_map.get(cls.__module__):
|
||||
metadata = StarMetadata(
|
||||
star_cls_type=cls,
|
||||
module_path=cls.__module__,
|
||||
)
|
||||
star_map[cls.__module__] = metadata
|
||||
star_registry.append(metadata)
|
||||
else:
|
||||
star_map[cls.__module__].star_cls_type = cls
|
||||
star_map[cls.__module__].module_path = cls.__module__
|
||||
|
||||
async def text_to_image(self, text: str, return_url=True) -> str:
|
||||
"""将文本转换为图片"""
|
||||
config_obj = self._get_context_config()
|
||||
template_name = None
|
||||
if hasattr(config_obj, "get"):
|
||||
try:
|
||||
template_name = config_obj.get("t2i_active_template")
|
||||
except Exception:
|
||||
template_name = None
|
||||
return await html_renderer.render_t2i(
|
||||
text,
|
||||
return_url=return_url,
|
||||
template_name=template_name,
|
||||
)
|
||||
|
||||
async def html_render(
|
||||
self,
|
||||
tmpl: str,
|
||||
data: dict,
|
||||
return_url=True,
|
||||
options: dict | None = None,
|
||||
) -> str:
|
||||
"""渲染 HTML"""
|
||||
return await html_renderer.render_custom_template(
|
||||
tmpl,
|
||||
data,
|
||||
return_url=return_url,
|
||||
options=options,
|
||||
)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""当插件被激活时会调用这个方法"""
|
||||
|
||||
async def terminate(self) -> None:
|
||||
"""当插件被禁用、重载插件时会调用这个方法"""
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""[Deprecated] 当插件被禁用、重载插件时会调用这个方法"""
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from asyncio import Queue
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
from deprecated import deprecated
|
||||
|
||||
@@ -12,14 +14,12 @@ from astrbot.core.agent.tool import ToolSet
|
||||
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.conversation_mgr import ConversationManager
|
||||
from astrbot.core.cron.manager import CronJobManager
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.persona_mgr import PersonaManager
|
||||
from astrbot.core.platform import Platform
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent, MessageSesion
|
||||
from astrbot.core.platform.manager import PlatformManager
|
||||
from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager
|
||||
from astrbot.core.provider.entities import LLMResponse, ProviderRequest, ProviderType
|
||||
from astrbot.core.provider.func_tool_manager import FunctionTool, FunctionToolManager
|
||||
@@ -45,6 +45,15 @@ from .star_handler import EventType, StarHandlerMetadata, star_handlers_registry
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.cron.manager import CronJobManager
|
||||
else:
|
||||
CronJobManager = Any
|
||||
|
||||
|
||||
class PlatformManagerProtocol(Protocol):
|
||||
platform_insts: list[Platform]
|
||||
|
||||
|
||||
class Context:
|
||||
"""暴露给插件的接口上下文。"""
|
||||
@@ -61,7 +70,7 @@ class Context:
|
||||
config: AstrBotConfig,
|
||||
db: BaseDatabase,
|
||||
provider_manager: ProviderManager,
|
||||
platform_manager: PlatformManager,
|
||||
platform_manager: PlatformManagerProtocol,
|
||||
conversation_manager: ConversationManager,
|
||||
message_history_manager: PlatformMessageHistoryManager,
|
||||
persona_manager: PersonaManager,
|
||||
@@ -448,6 +457,9 @@ class Context:
|
||||
if platform.meta().id == session.platform_name:
|
||||
await platform.send_by_session(session, message_chain)
|
||||
return True
|
||||
logger.warning(
|
||||
f"cannot find platform for session {str(session)}, message not sent"
|
||||
)
|
||||
return False
|
||||
|
||||
def add_llm_tools(self, *tools: FunctionTool) -> None:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import warnings
|
||||
|
||||
from astrbot.core.star import StarMetadata, star_map
|
||||
from astrbot.core.star.star import StarMetadata, star_map
|
||||
|
||||
_warned_register_star = False
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from astrbot.core.agent.agent import Agent
|
||||
from astrbot.core.agent.handoff import HandoffTool
|
||||
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.message.message_event_result import MessageEventResult
|
||||
from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
@@ -617,7 +616,7 @@ class RegisteringAgent:
|
||||
kwargs["registering_agent"] = self
|
||||
return register_llm_tool(*args, **kwargs)
|
||||
|
||||
def __init__(self, agent: Agent[AstrAgentContext]) -> None:
|
||||
def __init__(self, agent: Agent[Any]) -> None:
|
||||
self._agent = agent
|
||||
|
||||
|
||||
@@ -625,7 +624,7 @@ def register_agent(
|
||||
name: str,
|
||||
instruction: str,
|
||||
tools: list[str | FunctionTool] | None = None,
|
||||
run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None,
|
||||
run_hooks: BaseAgentRunHooks[Any] | None = None,
|
||||
):
|
||||
"""注册一个 Agent
|
||||
|
||||
@@ -639,12 +638,12 @@ def register_agent(
|
||||
tools_ = tools or []
|
||||
|
||||
def decorator(awaitable: Callable[..., Awaitable[Any]]):
|
||||
AstrAgent = Agent[AstrAgentContext]
|
||||
AstrAgent = Agent[Any]
|
||||
agent = AstrAgent(
|
||||
name=name,
|
||||
instructions=instruction,
|
||||
tools=tools_,
|
||||
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](),
|
||||
run_hooks=run_hooks or BaseAgentRunHooks[Any](),
|
||||
)
|
||||
handoff_tool = HandoffTool(agent=agent)
|
||||
handoff_tool.handler = awaitable
|
||||
|
||||
@@ -105,6 +105,22 @@ class StarHandlerRegistry(Generic[T]):
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
event_type: Literal[EventType.OnPluginLoadedEvent],
|
||||
only_activated=True,
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
event_type: Literal[EventType.OnPluginUnloadedEvent],
|
||||
only_activated=True,
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
|
||||
@@ -49,10 +49,13 @@ class PluginVersionIncompatibleError(Exception):
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, context: Context, config: AstrBotConfig) -> None:
|
||||
from .star_tools import StarTools
|
||||
|
||||
self.updator = PluginUpdator()
|
||||
|
||||
self.context = context
|
||||
self.context._star_manager = self # type: ignore
|
||||
StarTools.initialize(context)
|
||||
|
||||
self.config = config
|
||||
self.plugin_store_path = get_astrbot_plugin_path()
|
||||
@@ -385,6 +388,33 @@ class PluginManager:
|
||||
except KeyError:
|
||||
logger.warning(f"模块 {module_name} 未载入")
|
||||
|
||||
def _cleanup_plugin_state(self, dir_name: str) -> None:
|
||||
plugin_root_name = "data.plugins."
|
||||
|
||||
# 清理 sys.modules
|
||||
for key in list(sys.modules.keys()):
|
||||
if key.startswith(f"{plugin_root_name}{dir_name}"):
|
||||
logger.info(f"清除了插件{dir_name}中的{key}模块")
|
||||
del sys.modules[key]
|
||||
|
||||
possible_paths = [
|
||||
f"{plugin_root_name}{dir_name}.main",
|
||||
f"{plugin_root_name}{dir_name}.{dir_name}",
|
||||
]
|
||||
|
||||
# 清理 handlers
|
||||
for path in possible_paths:
|
||||
handlers = star_handlers_registry.get_handlers_by_module_name(path)
|
||||
for handler in handlers:
|
||||
star_handlers_registry.remove(handler)
|
||||
logger.info(f"清理处理器: {handler.handler_name}")
|
||||
|
||||
# 清理工具
|
||||
for tool in list(llm_tools.func_list):
|
||||
if tool.handler_module_path in possible_paths:
|
||||
llm_tools.func_list.remove(tool)
|
||||
logger.info(f"清理工具: {tool.name}")
|
||||
|
||||
async def reload_failed_plugin(self, dir_name):
|
||||
"""
|
||||
重新加载未注册(加载失败)的插件
|
||||
@@ -395,17 +425,21 @@ class PluginManager:
|
||||
- success (bool): 重载是否成功
|
||||
- error_message (str|None): 错误信息,成功时为 None
|
||||
"""
|
||||
|
||||
async with self._pm_lock:
|
||||
if dir_name in self.failed_plugin_dict:
|
||||
success, error = await self.load(specified_dir_name=dir_name)
|
||||
if success:
|
||||
self.failed_plugin_dict.pop(dir_name, None)
|
||||
if not self.failed_plugin_dict:
|
||||
self.failed_plugin_info = ""
|
||||
return success, None
|
||||
else:
|
||||
return False, error
|
||||
return False, "插件不存在于失败列表中"
|
||||
if dir_name not in self.failed_plugin_dict:
|
||||
return False, "插件不存在于失败列表中"
|
||||
|
||||
self._cleanup_plugin_state(dir_name)
|
||||
|
||||
success, error = await self.load(specified_dir_name=dir_name)
|
||||
if success:
|
||||
self.failed_plugin_dict.pop(dir_name, None)
|
||||
if not self.failed_plugin_dict:
|
||||
self.failed_plugin_info = ""
|
||||
return success, None
|
||||
else:
|
||||
return False, error
|
||||
|
||||
async def reload(self, specified_plugin_name=None):
|
||||
"""重新加载插件
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.dataclasses import dataclass
|
||||
@@ -8,6 +9,14 @@ from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
|
||||
def _extract_job_session(job: Any) -> str | None:
|
||||
payload = getattr(job, "payload", None)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
session = payload.get("session")
|
||||
return str(session) if session is not None else None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "create_future_task"
|
||||
@@ -119,9 +128,15 @@ class DeleteCronJobTool(FunctionTool[AstrAgentContext]):
|
||||
cron_mgr = context.context.context.cron_manager
|
||||
if cron_mgr is None:
|
||||
return "error: cron manager is not available."
|
||||
current_umo = context.context.event.unified_msg_origin
|
||||
job_id = kwargs.get("job_id")
|
||||
if not job_id:
|
||||
return "error: job_id is required."
|
||||
job = await cron_mgr.db.get_cron_job(str(job_id))
|
||||
if not job:
|
||||
return f"error: cron job {job_id} not found."
|
||||
if _extract_job_session(job) != current_umo:
|
||||
return "error: you can only delete future tasks in the current umo."
|
||||
await cron_mgr.delete_job(str(job_id))
|
||||
return f"Deleted cron job {job_id}."
|
||||
|
||||
@@ -148,8 +163,13 @@ class ListCronJobsTool(FunctionTool[AstrAgentContext]):
|
||||
cron_mgr = context.context.context.cron_manager
|
||||
if cron_mgr is None:
|
||||
return "error: cron manager is not available."
|
||||
current_umo = context.context.event.unified_msg_origin
|
||||
job_type = kwargs.get("job_type")
|
||||
jobs = await cron_mgr.list_jobs(job_type)
|
||||
jobs = [
|
||||
job
|
||||
for job in await cron_mgr.list_jobs(job_type)
|
||||
if _extract_job_session(job) == current_umo
|
||||
]
|
||||
if not jobs:
|
||||
return "No cron jobs found."
|
||||
lines = []
|
||||
|
||||
@@ -19,7 +19,7 @@ from astrbot.core.message.components import (
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
|
||||
|
||||
from .image_refs import looks_like_image_file_name, normalize_file_like_url
|
||||
from .image_refs import looks_like_image_file_name
|
||||
from .settings import SETTINGS, QuotedMessageParserSettings
|
||||
|
||||
_FORWARD_PLACEHOLDER_PATTERN = re.compile(
|
||||
@@ -296,11 +296,11 @@ def _parse_onebot_segments(
|
||||
or "file"
|
||||
)
|
||||
text_parts.append(f"[File:{file_name}]")
|
||||
candidate_url = seg_data.get("url")
|
||||
candidate_url = seg_data.get("url", "")
|
||||
if (
|
||||
isinstance(candidate_url, str)
|
||||
and candidate_url.strip()
|
||||
and looks_like_image_file_name(normalize_file_like_url(candidate_url))
|
||||
and looks_like_image_file_name(candidate_url)
|
||||
):
|
||||
image_refs.append(candidate_url.strip())
|
||||
candidate_file = seg_data.get("file")
|
||||
@@ -308,11 +308,7 @@ def _parse_onebot_segments(
|
||||
isinstance(candidate_file, str)
|
||||
and candidate_file.strip()
|
||||
and looks_like_image_file_name(
|
||||
normalize_file_like_url(
|
||||
seg_data.get("name")
|
||||
or seg_data.get("file_name")
|
||||
or candidate_file
|
||||
)
|
||||
seg_data.get("name") or seg_data.get("file_name") or candidate_file
|
||||
)
|
||||
):
|
||||
image_refs.append(candidate_file.strip())
|
||||
@@ -368,7 +364,9 @@ def _extract_text_forward_ids_and_images_from_forward_nodes(
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
sender = node.get("sender") if isinstance(node.get("sender"), dict) else {}
|
||||
sender = node.get("sender")
|
||||
if not isinstance(sender, dict):
|
||||
sender = {}
|
||||
sender_name = (
|
||||
sender.get("nickname")
|
||||
or sender.get("card")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from collections.abc import Awaitable
|
||||
from typing import Any, Protocol
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
@@ -17,6 +18,10 @@ def _unwrap_action_response(ret: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return ret
|
||||
|
||||
|
||||
class CallAction(Protocol):
|
||||
def __call__(self, action: str, **params: Any) -> Awaitable[Any] | Any: ...
|
||||
|
||||
|
||||
class OneBotClient:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -27,7 +32,7 @@ class OneBotClient:
|
||||
self._settings = settings
|
||||
|
||||
@staticmethod
|
||||
def _resolve_call_action(event: AstrMessageEvent):
|
||||
def _resolve_call_action(event: AstrMessageEvent) -> CallAction | None:
|
||||
bot = getattr(event, "bot", None)
|
||||
api = getattr(bot, "api", None)
|
||||
call_action = getattr(api, "call_action", None)
|
||||
|
||||
@@ -754,6 +754,22 @@ class ConfigRoute(Route):
|
||||
if not provider_type:
|
||||
return Response().error("provider_config 缺少 type 字段").__dict__
|
||||
|
||||
# 首次添加某类提供商时,provider_cls_map 可能尚未注册该适配器
|
||||
if provider_type not in provider_cls_map:
|
||||
try:
|
||||
self.core_lifecycle.provider_manager.dynamic_import_provider(
|
||||
provider_type,
|
||||
)
|
||||
except ImportError:
|
||||
logger.error(traceback.format_exc())
|
||||
return (
|
||||
Response()
|
||||
.error(
|
||||
"提供商适配器加载失败,请检查提供商类型配置或查看服务端日志"
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
# 获取对应的 provider 类
|
||||
if provider_type not in provider_cls_map:
|
||||
return (
|
||||
@@ -779,7 +795,7 @@ class ConfigRoute(Route):
|
||||
if inspect.iscoroutinefunction(init_fn):
|
||||
await init_fn()
|
||||
|
||||
# 获取嵌入向量维度
|
||||
# 通过实际请求验证当前 embedding_dimensions 是否可用
|
||||
vec = await inst.get_embedding("echo")
|
||||
dim = len(vec)
|
||||
|
||||
|
||||
@@ -148,7 +148,6 @@ class ConversationRoute(Route):
|
||||
user_id = data.get("user_id")
|
||||
cid = data.get("cid")
|
||||
title = data.get("title")
|
||||
persona_id = data.get("persona_id", "")
|
||||
|
||||
if not user_id or not cid:
|
||||
return Response().error("缺少必要参数: user_id 和 cid").__dict__
|
||||
@@ -158,6 +157,9 @@ class ConversationRoute(Route):
|
||||
)
|
||||
if not conversation:
|
||||
return Response().error("对话不存在").__dict__
|
||||
|
||||
persona_id = data.get("persona_id", conversation.persona_id)
|
||||
|
||||
if title is not None or persona_id is not None:
|
||||
await self.conv_mgr.update_conversation(
|
||||
unified_msg_origin=user_id,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
- 新增 Agent 会话停止能力,并优化 stop 请求处理流程,支持 /stop 指令终止 Agent 运行并尽量不丢失已运行输出的结果。 ([#5380](https://github.com/AstrBotDevs/AstrBot/issues/5380))。
|
||||
- 新增 SubAgent 交接场景下的 computer-use 工具支持 ([#5399](https://github.com/AstrBotDevs/AstrBot/issues/5399))。
|
||||
- 新增 Agent 执行过程中展示工具调用结果的能力,提升执行过程可观测性 ([#5388](https://github.com/AstrBotDevs/AstrBot/issues/5388))。
|
||||
- 新增插件加载/卸载 Hook,扩展插件生命周期能力 ([#5331](https://github.com/AstrBotDevs/AstrBot/issues/5331))。
|
||||
- 新增插件加载失败后的热重载能力,提升插件开发与恢复效率 ([#5334](https://github.com/AstrBotDevs/AstrBot/issues/5334))。
|
||||
- 新增 SubAgent 图片 URL/本地路径输入支持 ([#5348](https://github.com/AstrBotDevs/AstrBot/issues/5348))。
|
||||
- 新增 Dashboard 发布跳转基础 URL 可配置项 ([#5330](https://github.com/AstrBotDevs/AstrBot/issues/5330))。
|
||||
|
||||
### 修复
|
||||
- 修复 Tavily 请求的硬编码 6 秒超时。
|
||||
- 修复 OneBot v11 适配器关闭之后仍然在连接的问题([#5412](https://github.com/AstrBotDevs/AstrBot/issues/5412))。
|
||||
- 修复上下文会话中平台缺失时的日志处理,补充 warning 并改进排查信息。
|
||||
- 修复 embedding 维度未透传到 provider API 的问题 ([#5411](https://github.com/AstrBotDevs/AstrBot/issues/5411))。
|
||||
- 修复 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性 ([#5391](https://github.com/AstrBotDevs/AstrBot/issues/5391))。
|
||||
- 修复 sandbox 文件传输工具缺少管理员权限校验的问题 ([#5402](https://github.com/AstrBotDevs/AstrBot/issues/5402))。
|
||||
- 修复 pipeline 与 `from ... import *` 引发的循环依赖问题 ([#5353](https://github.com/AstrBotDevs/AstrBot/issues/5353))。
|
||||
- 修复配置文件存在 UTF-8 BOM 时的解析问题 ([#5376](https://github.com/AstrBotDevs/AstrBot/issues/5376))。
|
||||
- 修复 ChatUI 复制回滚路径缺失与错误提示不清晰的问题 ([#5352](https://github.com/AstrBotDevs/AstrBot/issues/5352))。
|
||||
- 修复保留插件目录处理逻辑,避免插件目录行为异常 ([#5369](https://github.com/AstrBotDevs/AstrBot/issues/5369))。
|
||||
- 修复 ChatUI 文件消息段无法持久化的问题 ([#5386](https://github.com/AstrBotDevs/AstrBot/issues/5386))。
|
||||
- 修复 `.dockerignore` 误排除 `changelogs` 目录的问题。
|
||||
- 修复 aiohttp 版本过新导致 qq-botpy 报错的问题 ([#5316](https://github.com/AstrBotDevs/AstrBot/issues/5316))。
|
||||
|
||||
### 优化
|
||||
- 完成 SubAgent 编排页面国际化,补齐多语言支持 ([#5400](https://github.com/AstrBotDevs/AstrBot/issues/5400))。
|
||||
- 增补消息事件处理相关测试,并完善测试框架的 fixtures/mocks 覆盖 ([#5355](https://github.com/AstrBotDevs/AstrBot/issues/5355), [#5354](https://github.com/AstrBotDevs/AstrBot/issues/5354))。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
- Added computer-use tools support in sub-agent handoff scenarios ([#5399](https://github.com/AstrBotDevs/AstrBot/issues/5399)).
|
||||
- Added support for displaying tool call results during agent execution for better observability ([#5388](https://github.com/AstrBotDevs/AstrBot/issues/5388)).
|
||||
- Added plugin load/unload hooks to extend plugin lifecycle capabilities ([#5331](https://github.com/AstrBotDevs/AstrBot/issues/5331)).
|
||||
- Added hot reload support when plugin loading fails, improving recovery during plugin development ([#5334](https://github.com/AstrBotDevs/AstrBot/issues/5334)).
|
||||
- Added image URL/local path input support for sub-agents ([#5348](https://github.com/AstrBotDevs/AstrBot/issues/5348)).
|
||||
- Added stop control for active agent sessions and improved stop request handling ([#5380](https://github.com/AstrBotDevs/AstrBot/issues/5380)).
|
||||
- Added configurable base URL for dashboard release redirects ([#5330](https://github.com/AstrBotDevs/AstrBot/issues/5330)).
|
||||
|
||||
### Fixes
|
||||
- Fixed logging behavior when platform information is missing in context sessions, with clearer warning and diagnostics.
|
||||
- Fixed missing embedding dimensions being passed to provider APIs ([#5411](https://github.com/AstrBotDevs/AstrBot/issues/5411)).
|
||||
- Fixed shutdown stability issues in the aiocqhttp adapter ([#5412](https://github.com/AstrBotDevs/AstrBot/issues/5412)).
|
||||
- Fixed File component handling and improved path compatibility in the OneBot driver layer ([#5391](https://github.com/AstrBotDevs/AstrBot/issues/5391)).
|
||||
- Fixed missing admin guard for sandbox file transfer tools ([#5402](https://github.com/AstrBotDevs/AstrBot/issues/5402)).
|
||||
- Fixed circular import issues related to pipeline and `from ... import *` usage ([#5353](https://github.com/AstrBotDevs/AstrBot/issues/5353)).
|
||||
- Fixed config parsing issues when files contain UTF-8 BOM ([#5376](https://github.com/AstrBotDevs/AstrBot/issues/5376)).
|
||||
- Fixed missing copy rollback path and unclear error messaging in ChatUI ([#5352](https://github.com/AstrBotDevs/AstrBot/issues/5352)).
|
||||
- Fixed reserved plugin directory handling to avoid abnormal plugin path behavior ([#5369](https://github.com/AstrBotDevs/AstrBot/issues/5369)).
|
||||
- Fixed ChatUI file segment persistence issues ([#5386](https://github.com/AstrBotDevs/AstrBot/issues/5386)).
|
||||
- Fixed accidental exclusion of the `changelogs` directory in `.dockerignore`.
|
||||
- Fixed compatibility issues caused by a hard-coded 6-second timeout in Tavily requests.
|
||||
- Fixed qq-botpy runtime errors caused by overly new aiohttp versions ([#5316](https://github.com/AstrBotDevs/AstrBot/issues/5316)).
|
||||
|
||||
### Improvements
|
||||
- Completed internationalization for the sub-agent orchestration page ([#5400](https://github.com/AstrBotDevs/AstrBot/issues/5400)).
|
||||
- Added broader message-event test coverage and improved fixtures/mocks in the test framework ([#5355](https://github.com/AstrBotDevs/AstrBot/issues/5355), [#5354](https://github.com/AstrBotDevs/AstrBot/issues/5354)).
|
||||
- Updated README content and applied repository-wide formatting cleanup (ruff format) ([#5375](https://github.com/AstrBotDevs/AstrBot/issues/5375)).
|
||||
@@ -0,0 +1,49 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增桌面端通用更新桥接能力,便于接入客户端内更新流程 ([#5424](https://github.com/AstrBotDevs/AstrBot/issues/5424))。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复新增平台对话框中 Line 适配器未显示的问题。
|
||||
- 修复 Telegram 无法发送 Video 的问题 ([#5430](https://github.com/AstrBotDevs/AstrBot/issues/5430))。
|
||||
- 修复创建 embedding provider 时无法自动识别向量维度的问题 ([#5442](https://github.com/AstrBotDevs/AstrBot/issues/5442))。
|
||||
- 修复 QQ 官方平台发送媒体消息时 markdown 字段未清理的问题 ([#5445](https://github.com/AstrBotDevs/AstrBot/issues/5445))。
|
||||
- 修复上下文管理策略 -> 上下文截断时 tool call / response 配对丢失的问题 ([#5417](https://github.com/AstrBotDevs/AstrBot/issues/5417))。
|
||||
- 修复会话更新时 `persona_id` 被覆盖的问题,并增强 persona 解析逻辑。
|
||||
- 修复 WebUI 中 GitHub 代理地址显示异常的问题 ([#5438](https://github.com/AstrBotDevs/AstrBot/issues/5438))。
|
||||
- 修复设置页新建开发者 API Key 后复制失败的问题 ([#5439](https://github.com/AstrBotDevs/AstrBot/issues/5439))。
|
||||
- 修复 Telegram 语音消息格式与 OpenAI STT 兼容性问题(使用 OGG) ([#5389](https://github.com/AstrBotDevs/AstrBot/issues/5389))。
|
||||
|
||||
### 优化
|
||||
|
||||
- 优化知识库检索流程,改为批量查询元数据,修复 N+1 查询性能问题 ([#5463](https://github.com/AstrBotDevs/AstrBot/issues/5463))。
|
||||
- 优化 Cron 未来任务执行的会话隔离能力,提升并发稳定性。
|
||||
- 优化 WebUI 插件页的交互。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
|
||||
- Added `useExtensionPage` composable for unified plugin extension page state management.
|
||||
- Added a generic desktop app updater bridge to support in-app update workflows ([#5424](https://github.com/AstrBotDevs/AstrBot/issues/5424)).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed the Line adapter not appearing in the "Add Platform" dialog.
|
||||
- Fixed Telegram video sending issues ([#5430](https://github.com/AstrBotDevs/AstrBot/issues/5430)).
|
||||
- Fixed Pyright static type checking errors ([#5437](https://github.com/AstrBotDevs/AstrBot/issues/5437)).
|
||||
- Fixed embedding dimension auto-detection when creating embedding providers ([#5442](https://github.com/AstrBotDevs/AstrBot/issues/5442)).
|
||||
- Fixed stale markdown fields when sending media messages via QQ Official Platform ([#5445](https://github.com/AstrBotDevs/AstrBot/issues/5445)).
|
||||
- Fixed tool call/response pairing loss during context truncation ([#5417](https://github.com/AstrBotDevs/AstrBot/issues/5417)).
|
||||
- Fixed `persona_id` being overwritten during conversation updates and improved persona resolution logic.
|
||||
- Fixed incorrect GitHub proxy display in WebUI ([#5438](https://github.com/AstrBotDevs/AstrBot/issues/5438)).
|
||||
- Fixed API key copy failure after creating a new key in settings ([#5439](https://github.com/AstrBotDevs/AstrBot/issues/5439)).
|
||||
- Fixed Telegram voice format compatibility with OpenAI STT by using OGG ([#5389](https://github.com/AstrBotDevs/AstrBot/issues/5389)).
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improved knowledge base retrieval by batching metadata queries to eliminate the N+1 query pattern ([#5463](https://github.com/AstrBotDevs/AstrBot/issues/5463)).
|
||||
- Improved session isolation for future cron tasks to increase stability under concurrency.
|
||||
- Improved WebUI plugin page interactions.
|
||||
@@ -34,6 +34,7 @@ const platformDisplayList = computed(() =>
|
||||
const handleInstall = (plugin) => {
|
||||
emit("install", plugin);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -123,6 +124,7 @@ const handleInstall = (plugin) => {
|
||||
v-if="plugin?.social_link"
|
||||
:href="plugin.social_link"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
class="text-subtitle-2 font-weight-medium"
|
||||
style="
|
||||
text-decoration: none;
|
||||
@@ -213,7 +215,10 @@ const handleInstall = (plugin) => {
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions style="gap: 6px; padding: 8px 12px; padding-top: 0">
|
||||
<v-card-actions
|
||||
style="gap: 6px; padding: 8px 12px; padding-top: 0"
|
||||
@click.stop
|
||||
>
|
||||
<v-chip
|
||||
v-for="tag in plugin.tags?.slice(0, 2)"
|
||||
:key="tag"
|
||||
@@ -248,22 +253,24 @@ const handleInstall = (plugin) => {
|
||||
<v-btn
|
||||
v-if="plugin?.repo"
|
||||
color="secondary"
|
||||
size="x-small"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="market-action-btn"
|
||||
:href="plugin.repo"
|
||||
target="_blank"
|
||||
style="height: 24px"
|
||||
style="height: 32px"
|
||||
>
|
||||
<v-icon icon="mdi-github" start size="x-small"></v-icon>
|
||||
<v-icon icon="mdi-github" start size="small"></v-icon>
|
||||
{{ tm("buttons.viewRepo") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!plugin?.installed"
|
||||
color="primary"
|
||||
size="x-small"
|
||||
size="small"
|
||||
@click="handleInstall(plugin)"
|
||||
variant="flat"
|
||||
style="height: 24px"
|
||||
class="market-action-btn"
|
||||
style="height: 32px"
|
||||
>
|
||||
{{ tm("buttons.install") }}
|
||||
</v-btn>
|
||||
@@ -306,4 +313,9 @@ const handleInstall = (plugin) => {
|
||||
.plugin-description::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(var(--v-theme-primary-rgb), 0.6);
|
||||
}
|
||||
|
||||
.market-action-btn {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,6 +48,40 @@ const filteredIterable = computed(() => {
|
||||
return rest
|
||||
})
|
||||
|
||||
const providerHint = computed(() => {
|
||||
const hint = props.iterable?.hint
|
||||
if (typeof hint !== 'string' || !hint) return ''
|
||||
|
||||
if (
|
||||
hint === 'provider_group.provider.openai_embedding.hint'
|
||||
|| hint === 'provider_group.provider.gemini_embedding.hint'
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return hint
|
||||
})
|
||||
|
||||
const getItemHint = (itemKey, itemMeta) => {
|
||||
if (itemMeta?.hint) return itemMeta.hint
|
||||
|
||||
if (itemKey !== 'embedding_api_base') return ''
|
||||
|
||||
const providerType = props.iterable?.type
|
||||
if (providerType === 'openai_embedding') {
|
||||
return getRaw('provider_group.provider.openai_embedding.hint')
|
||||
? 'provider_group.provider.openai_embedding.hint'
|
||||
: ''
|
||||
}
|
||||
if (providerType === 'gemini_embedding') {
|
||||
return getRaw('provider_group.provider.gemini_embedding.hint')
|
||||
? 'provider_group.provider.gemini_embedding.hint'
|
||||
: ''
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const dialog = ref(false)
|
||||
const currentEditingKey = ref('')
|
||||
const currentEditingLanguage = ref('json')
|
||||
@@ -153,14 +187,14 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
<div v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template" class="object-config">
|
||||
<!-- Provider-level hint -->
|
||||
<v-alert
|
||||
v-if="iterable.hint && !isEditing"
|
||||
v-if="providerHint"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
border="start"
|
||||
density="compact"
|
||||
>
|
||||
{{ iterable.hint }}
|
||||
{{ translateIfKey(providerHint) }}
|
||||
</v-alert>
|
||||
|
||||
<div v-for="(val, key, index) in filteredIterable" :key="key" class="config-item">
|
||||
@@ -218,9 +252,9 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="property-hint">
|
||||
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
|
||||
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && getItemHint(key, metadata[metadataKey].items[key])"
|
||||
class="important-hint">‼️</span>
|
||||
{{ translateIfKey(metadata[metadataKey].items[key]?.hint) }}
|
||||
{{ translateIfKey(getItemHint(key, metadata[metadataKey].items[key])) }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, inject } from "vue";
|
||||
import { ref, computed, inject, watch } from "vue";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { getPlatformDisplayName, getPlatformIcon } from "@/utils/platformUtils";
|
||||
import UninstallConfirmDialog from "./UninstallConfirmDialog.vue";
|
||||
import PluginPlatformChip from "./PluginPlatformChip.vue";
|
||||
import StyledMenu from "./StyledMenu.vue";
|
||||
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
|
||||
|
||||
const props = defineProps({
|
||||
extension: {
|
||||
@@ -59,6 +61,25 @@ const astrbotVersionRequirement = computed(() => {
|
||||
: "";
|
||||
});
|
||||
|
||||
const logoLoadFailed = ref(false);
|
||||
|
||||
const logoSrc = computed(() => {
|
||||
const logo = props.extension?.logo;
|
||||
if (logoLoadFailed.value) {
|
||||
return defaultPluginIcon;
|
||||
}
|
||||
return typeof logo === "string" && logo.trim().length
|
||||
? logo
|
||||
: defaultPluginIcon;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.extension?.logo,
|
||||
() => {
|
||||
logoLoadFailed.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
// 操作函数
|
||||
const configure = () => {
|
||||
emit("configure", props.extension);
|
||||
@@ -104,6 +125,7 @@ const viewReadme = () => {
|
||||
const viewChangelog = () => {
|
||||
emit("view-changelog", props.extension);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -129,249 +151,292 @@ const viewChangelog = () => {
|
||||
style="
|
||||
padding: 16px;
|
||||
padding-bottom: 0px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<div v-if="extension?.logo">
|
||||
<img :src="extension.logo" :alt="extension.name" cover width="100" />
|
||||
</div>
|
||||
|
||||
<div style="overflow-x: auto">
|
||||
<!-- Top-right three-dot menu -->
|
||||
<div style="position: absolute; right: 8px; top: 8px; z-index: 5">
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
aria-label="more"
|
||||
v-if="extension?.repo"
|
||||
:href="extension?.repo"
|
||||
target="_blank"
|
||||
>
|
||||
<v-icon icon="mdi-github"></v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-bind="menuProps" icon variant="text" aria-label="more">
|
||||
<v-icon icon="mdi-dots-vertical"></v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item @click="viewReadme">
|
||||
<v-list-item-title
|
||||
>📄 {{ tm("buttons.viewDocs") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-if="!marketMode" @click="viewChangelog">
|
||||
<v-list-item-title
|
||||
>📝 {{ tm("pluginChangelog.menuTitle") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
v-if="marketMode && !extension?.installed"
|
||||
@click="installExtension"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ tm("buttons.install") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-if="marketMode && extension?.installed">
|
||||
<v-list-item-title class="text--disabled">{{
|
||||
tm("status.installed")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Divider between market actions and plugin actions -->
|
||||
<v-divider v-if="!marketMode" />
|
||||
|
||||
<template v-if="!marketMode">
|
||||
<v-list-item @click="configure">
|
||||
<v-list-item-title>
|
||||
{{ tm("card.actions.pluginConfig") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="uninstallExtension">
|
||||
<v-list-item-title class="text-error">{{
|
||||
tm("card.actions.uninstallPlugin")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="reloadExtension">
|
||||
<v-list-item-title>{{
|
||||
tm("card.actions.reloadPlugin")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="toggleActivation">
|
||||
<v-list-item-title>
|
||||
{{
|
||||
extension.activated
|
||||
? tm("buttons.disable")
|
||||
: tm("buttons.enable")
|
||||
}}{{ tm("card.actions.togglePlugin") }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="viewHandlers">
|
||||
<v-list-item-title
|
||||
>{{ tm("card.actions.viewHandlers") }} ({{
|
||||
extension.handlers.length
|
||||
}})</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="updateExtension">
|
||||
<v-list-item-title>
|
||||
{{
|
||||
extension.has_update
|
||||
? tm("card.actions.updateTo") +
|
||||
" " +
|
||||
extension.online_version
|
||||
: tm("card.actions.reinstall")
|
||||
}}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<div style="overflow-x: auto; width: 100%">
|
||||
<div style="width: 100%; margin-bottom: 24px">
|
||||
<!-- 最多一行 -->
|
||||
<div
|
||||
class="text-caption"
|
||||
style="
|
||||
color: gray;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 84px;
|
||||
"
|
||||
>
|
||||
{{ extension.author }} / {{ extension.name }}
|
||||
</div>
|
||||
<p
|
||||
class="text-h3 font-weight-black extension-title"
|
||||
:class="{ 'text-h4': $vuetify.display.xs }"
|
||||
>
|
||||
<span class="extension-title__text">{{
|
||||
extension.display_name?.length
|
||||
? extension.display_name
|
||||
: extension.name
|
||||
}}</span>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
v-if="extension?.has_update && !marketMode"
|
||||
<div class="extension-title-row">
|
||||
<p
|
||||
class="text-h3 font-weight-black extension-title"
|
||||
:class="{ 'text-h4': $vuetify.display.xs }"
|
||||
>
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="warning"
|
||||
class="ml-2"
|
||||
icon="mdi-update"
|
||||
size="small"
|
||||
></v-icon>
|
||||
</template>
|
||||
<span
|
||||
>{{ tm("card.status.hasUpdate") }}:
|
||||
{{ extension.online_version }}</span
|
||||
<v-tooltip
|
||||
location="top"
|
||||
:text="
|
||||
extension.display_name?.length &&
|
||||
extension.display_name !== extension.name
|
||||
? `${extension.display_name} (${extension.name})`
|
||||
: extension.name
|
||||
"
|
||||
>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
v-if="!extension.activated && !marketMode"
|
||||
>
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="error"
|
||||
class="ml-2"
|
||||
icon="mdi-cancel"
|
||||
size="small"
|
||||
></v-icon>
|
||||
</template>
|
||||
<span>{{ tm("card.status.disabled") }}</span>
|
||||
</v-tooltip>
|
||||
</p>
|
||||
<template v-slot:activator="{ props: titleTooltipProps }">
|
||||
<span v-bind="titleTooltipProps" class="extension-title__text">{{
|
||||
extension.display_name?.length
|
||||
? extension.display_name
|
||||
: extension.name
|
||||
}}</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
v-if="extension?.has_update && !marketMode"
|
||||
>
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="warning"
|
||||
class="ml-2"
|
||||
icon="mdi-update"
|
||||
size="small"
|
||||
></v-icon>
|
||||
</template>
|
||||
<span
|
||||
>{{ tm("card.status.hasUpdate") }}:
|
||||
{{ extension.online_version }}</span
|
||||
>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
v-if="!extension.activated && !marketMode"
|
||||
>
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="error"
|
||||
class="ml-2"
|
||||
icon="mdi-cancel"
|
||||
size="small"
|
||||
></v-icon>
|
||||
</template>
|
||||
<span>{{ tm("card.status.disabled") }}</span>
|
||||
</v-tooltip>
|
||||
</p>
|
||||
|
||||
<div class="mt-1 d-flex flex-wrap">
|
||||
<v-chip color="primary" label size="small">
|
||||
<v-icon icon="mdi-source-branch" start></v-icon>
|
||||
{{ extension.version }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="extension?.has_update"
|
||||
color="warning"
|
||||
label
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
<v-icon icon="mdi-arrow-up-bold" start></v-icon>
|
||||
{{ extension.online_version }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
color="primary"
|
||||
label
|
||||
size="small"
|
||||
class="ml-2"
|
||||
v-if="extension.handlers?.length"
|
||||
@click="viewHandlers"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<v-icon icon="mdi-cogs" start></v-icon>
|
||||
{{ extension.handlers?.length
|
||||
}}{{ tm("card.status.handlersCount") }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-for="tag in extension.tags"
|
||||
:key="tag"
|
||||
:color="tag === 'danger' ? 'error' : 'primary'"
|
||||
label
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ tag === "danger" ? tm("tags.danger") : tag }}
|
||||
</v-chip>
|
||||
<PluginPlatformChip
|
||||
:platforms="supportPlatforms"
|
||||
class="ml-2"
|
||||
/>
|
||||
<v-chip
|
||||
v-if="astrbotVersionRequirement"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
label
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
AstrBot: {{ astrbotVersionRequirement }}
|
||||
</v-chip>
|
||||
<template v-if="!marketMode">
|
||||
<v-tooltip location="left">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<div v-bind="tooltipProps" class="extension-switch-wrap" @click.stop>
|
||||
<v-switch
|
||||
:model-value="extension.activated"
|
||||
color="success"
|
||||
density="compact"
|
||||
hide-details
|
||||
inset
|
||||
@update:model-value="toggleActivation"
|
||||
></v-switch>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{
|
||||
extension.activated ? tm("buttons.disable") : tm("buttons.enable")
|
||||
}}</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="extension-market-menu-wrap">
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
aria-label="more"
|
||||
v-if="extension?.repo"
|
||||
:href="extension?.repo"
|
||||
target="_blank"
|
||||
>
|
||||
<v-icon icon="mdi-github"></v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-bind="menuProps" icon variant="text" aria-label="more">
|
||||
<v-icon icon="mdi-dots-vertical"></v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item @click="viewReadme">
|
||||
<v-list-item-title
|
||||
>📄 {{ tm("buttons.viewDocs") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
v-if="marketMode && !extension?.installed"
|
||||
@click="installExtension"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ tm("buttons.install") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-if="marketMode && extension?.installed">
|
||||
<v-list-item-title class="text--disabled">{{
|
||||
tm("status.installed")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-2"
|
||||
:class="{ 'text-caption': $vuetify.display.xs }"
|
||||
style="overflow-y: auto; height: 70px; font-size: 90%"
|
||||
>
|
||||
{{ extension.desc }}
|
||||
<div class="extension-content-row mt-2">
|
||||
<div class="extension-image-container">
|
||||
<img
|
||||
:src="logoSrc"
|
||||
:alt="extension.name"
|
||||
class="extension-logo"
|
||||
@error="logoLoadFailed = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="extension-meta-group">
|
||||
<div class="extension-chip-group d-flex flex-wrap">
|
||||
<v-chip color="primary" label size="small">
|
||||
<v-icon icon="mdi-source-branch" start></v-icon>
|
||||
{{ extension.version }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="extension?.has_update"
|
||||
color="warning"
|
||||
label
|
||||
size="small"
|
||||
>
|
||||
<v-icon icon="mdi-arrow-up-bold" start></v-icon>
|
||||
{{ extension.online_version }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="extension.handlers?.length"
|
||||
color="primary"
|
||||
label
|
||||
size="small"
|
||||
@click="viewHandlers"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<v-icon icon="mdi-cogs" start></v-icon>
|
||||
{{ extension.handlers?.length
|
||||
}}{{ tm("card.status.handlersCount") }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-for="tag in extension.tags"
|
||||
:key="tag"
|
||||
:color="tag === 'danger' ? 'error' : 'primary'"
|
||||
label
|
||||
size="small"
|
||||
>
|
||||
{{ tag === "danger" ? tm("tags.danger") : tag }}
|
||||
</v-chip>
|
||||
<PluginPlatformChip :platforms="supportPlatforms" />
|
||||
<v-chip
|
||||
v-if="astrbotVersionRequirement"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
label
|
||||
size="small"
|
||||
>
|
||||
AstrBot: {{ astrbotVersionRequirement }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="extension-desc"
|
||||
:class="{ 'text-caption': $vuetify.display.xs }"
|
||||
>
|
||||
{{ extension.desc }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="extension-actions">
|
||||
<v-btn color="primary" size="small" @click="viewReadme">
|
||||
{{ tm("buttons.viewDocs") }}
|
||||
</v-btn>
|
||||
<v-btn v-if="!marketMode" color="primary" size="small" @click="configure">
|
||||
{{ tm("card.actions.pluginConfig") }}
|
||||
</v-btn>
|
||||
<v-card-actions class="extension-actions" @click.stop>
|
||||
<template v-if="!marketMode">
|
||||
<v-spacer></v-spacer>
|
||||
<v-tooltip location="top" :text="tm('buttons.viewDocs')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
v-bind="actionProps"
|
||||
icon="mdi-book-open-page-variant"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
@click="viewReadme"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" :text="tm('card.actions.pluginConfig')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
v-bind="actionProps"
|
||||
icon="mdi-cog"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click="configure"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip v-if="extension?.repo" location="top" :text="tm('buttons.viewRepo')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
v-bind="actionProps"
|
||||
icon="mdi-github"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
:href="extension.repo"
|
||||
target="_blank"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" :text="tm('card.actions.reloadPlugin')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
v-bind="actionProps"
|
||||
icon="mdi-refresh"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click="reloadExtension"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<StyledMenu location="top end" offset="8">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon="mdi-dots-horizontal"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
></v-btn>
|
||||
</template>
|
||||
|
||||
<v-list-item class="styled-menu-item" prepend-icon="mdi-information" @click="viewHandlers">
|
||||
<v-list-item-title>{{ tm("buttons.viewInfo") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="styled-menu-item" prepend-icon="mdi-update" @click="updateExtension">
|
||||
<v-list-item-title>{{
|
||||
extension.has_update
|
||||
? tm("card.actions.updateTo") + " " + extension.online_version
|
||||
: tm("card.actions.reinstall")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="styled-menu-item" prepend-icon="mdi-delete" @click="uninstallExtension">
|
||||
<v-list-item-title class="text-error">{{ tm("card.actions.uninstallPlugin") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</StyledMenu>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-btn color="primary" size="small" @click="viewReadme">
|
||||
{{ tm("buttons.viewDocs") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
@@ -385,13 +450,52 @@ const viewChangelog = () => {
|
||||
<style scoped>
|
||||
.extension-image-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 12px;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extension-logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.extension-content-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.extension-meta-group {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.extension-chip-group {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.extension-desc {
|
||||
margin-top: 8px;
|
||||
font-size: 90%;
|
||||
overflow-y: auto;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.extension-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.extension-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.extension-title__text {
|
||||
@@ -399,17 +503,38 @@ const viewChangelog = () => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.extension-switch-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extension-switch-wrap :deep(.v-switch) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.extension-market-menu-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.extension-image-container {
|
||||
margin-left: 8px;
|
||||
.extension-content-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.extension-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.extension-actions {
|
||||
margin-top: auto;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<v-expand-transition>
|
||||
<div v-if="radioValue === '1'" style="margin-left: 16px;">
|
||||
<v-radio-group v-model="githubProxyRadioControl" class="mt-2" hide-details="true">
|
||||
<v-radio color="success" v-for="(proxy, idx) in githubProxies" :key="proxy" :value="idx">
|
||||
<v-radio color="success" v-for="(proxy, idx) in githubProxies" :key="proxy" :value="String(idx)">
|
||||
<template v-slot:label>
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2">{{ proxy }}</span>
|
||||
@@ -37,7 +37,7 @@
|
||||
</template>
|
||||
</v-radio>
|
||||
<v-radio color="primary" value="-1" :label="tm('network.proxySelector.custom')">
|
||||
<template v-slot:label v-if="githubProxyRadioControl === '-1'">
|
||||
<template v-slot:label v-if="String(githubProxyRadioControl) === '-1'">
|
||||
<v-text-field density="compact" v-model="selectedGitHubProxy" variant="outlined"
|
||||
style="width: 100vw;" :placeholder="tm('network.proxySelector.custom')" hide-details="true">
|
||||
</v-text-field>
|
||||
@@ -72,9 +72,21 @@ export default {
|
||||
loadingTestingConnection: false,
|
||||
testingProxies: {},
|
||||
proxyStatus: {},
|
||||
initializing: true,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getProxyByControl(control) {
|
||||
const normalizedControl = String(control);
|
||||
if (normalizedControl === "-1") {
|
||||
return "";
|
||||
}
|
||||
const index = Number.parseInt(normalizedControl, 10);
|
||||
if (Number.isNaN(index)) {
|
||||
return "";
|
||||
}
|
||||
return this.githubProxies[index] || "";
|
||||
},
|
||||
async testSingleProxy(idx) {
|
||||
this.testingProxies[idx] = true;
|
||||
|
||||
@@ -118,42 +130,60 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.selectedGitHubProxy = localStorage.getItem('selectedGitHubProxy') || "";
|
||||
this.radioValue = localStorage.getItem('githubProxyRadioValue') || "0";
|
||||
this.githubProxyRadioControl = localStorage.getItem('githubProxyRadioControl') || "0";
|
||||
if (this.radioValue === "1") {
|
||||
if (this.githubProxyRadioControl !== "-1") {
|
||||
this.selectedGitHubProxy = this.githubProxies[this.githubProxyRadioControl] || "";
|
||||
this.initializing = true;
|
||||
|
||||
const savedProxy = localStorage.getItem('selectedGitHubProxy') || "";
|
||||
const savedRadio = localStorage.getItem('githubProxyRadioValue') || "0";
|
||||
const savedControl = String(localStorage.getItem('githubProxyRadioControl') || "0");
|
||||
|
||||
this.radioValue = savedRadio;
|
||||
this.githubProxyRadioControl = savedControl;
|
||||
|
||||
if (savedRadio === "1") {
|
||||
if (savedControl !== "-1") {
|
||||
this.selectedGitHubProxy = this.getProxyByControl(savedControl);
|
||||
} else {
|
||||
this.selectedGitHubProxy = savedProxy;
|
||||
}
|
||||
} else {
|
||||
this.selectedGitHubProxy = "";
|
||||
}
|
||||
|
||||
this.initializing = false;
|
||||
},
|
||||
watch: {
|
||||
selectedGitHubProxy: function (newVal, oldVal) {
|
||||
if (this.initializing) {
|
||||
return;
|
||||
}
|
||||
if (!newVal) {
|
||||
newVal = ""
|
||||
}
|
||||
localStorage.setItem('selectedGitHubProxy', newVal);
|
||||
},
|
||||
radioValue: function (newVal) {
|
||||
if (this.initializing) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('githubProxyRadioValue', newVal);
|
||||
if (newVal === "0") {
|
||||
if (String(newVal) === "0") {
|
||||
this.selectedGitHubProxy = "";
|
||||
} else if (this.githubProxyRadioControl !== "-1") {
|
||||
this.selectedGitHubProxy = this.githubProxies[this.githubProxyRadioControl] || "";
|
||||
} else if (String(this.githubProxyRadioControl) !== "-1") {
|
||||
this.selectedGitHubProxy = this.getProxyByControl(this.githubProxyRadioControl);
|
||||
}
|
||||
},
|
||||
githubProxyRadioControl: function (newVal) {
|
||||
localStorage.setItem('githubProxyRadioControl', newVal);
|
||||
if (this.radioValue !== "1") {
|
||||
if (this.initializing) {
|
||||
return;
|
||||
}
|
||||
const normalizedVal = String(newVal);
|
||||
localStorage.setItem('githubProxyRadioControl', normalizedVal);
|
||||
if (String(this.radioValue) !== "1") {
|
||||
this.selectedGitHubProxy = "";
|
||||
return;
|
||||
}
|
||||
if (newVal !== "-1") {
|
||||
this.selectedGitHubProxy = this.githubProxies[newVal] || "";
|
||||
} else {
|
||||
this.selectedGitHubProxy = "";
|
||||
if (normalizedVal !== "-1") {
|
||||
this.selectedGitHubProxy = this.getProxyByControl(normalizedVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,18 @@
|
||||
"guideStep2": "Install it and restart AstrBot.",
|
||||
"guideStep3": "If you use Docker, prefer the image update path."
|
||||
},
|
||||
"desktopApp": {
|
||||
"title": "Update Desktop App",
|
||||
"message": "Check and upgrade the AstrBot desktop application.",
|
||||
"currentVersion": "Current version: ",
|
||||
"latestVersion": "Latest version: ",
|
||||
"checking": "Checking desktop app updates...",
|
||||
"hasNewVersion": "A new version is available. Click confirm to upgrade.",
|
||||
"isLatest": "Already on the latest version",
|
||||
"installing": "Downloading and installing update. The app will restart automatically...",
|
||||
"checkFailed": "Failed to check updates. Please try again later.",
|
||||
"installFailed": "Upgrade failed. Please try again later."
|
||||
},
|
||||
"dashboardUpdate": {
|
||||
"title": "Update Dashboard to Latest Version Only",
|
||||
"currentVersion": "Current Version",
|
||||
|
||||
@@ -251,6 +251,10 @@
|
||||
"show_tool_use_status": {
|
||||
"description": "Output Function Call Status"
|
||||
},
|
||||
"show_tool_call_result": {
|
||||
"description": "Output Tool Call Results",
|
||||
"hint": "Only takes effect when \"Output Function Call Status\" is enabled, and shows at most 70 characters."
|
||||
},
|
||||
"sanitize_context_by_modalities": {
|
||||
"description": "Sanitize History by Modalities",
|
||||
"hint": "When enabled, sanitizes contexts before each LLM request by removing image blocks and tool-call structures that the current provider's modalities do not support (this changes what the model sees)."
|
||||
@@ -1082,6 +1086,12 @@
|
||||
"embedding_api_base": {
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"openai_embedding": {
|
||||
"hint": "OpenAI Embedding automatically appends /v1 at request time."
|
||||
},
|
||||
"gemini_embedding": {
|
||||
"hint": "Gemini Embedding does not require manually adding /v1beta."
|
||||
},
|
||||
"volcengine_cluster": {
|
||||
"description": "Volcengine cluster",
|
||||
"hint": "For voice cloning models, choose volcano_icl or volcano_icl_concurr; default is volcano_tts."
|
||||
@@ -1309,6 +1319,10 @@
|
||||
"api_base": {
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"proxy": {
|
||||
"description": "Proxy address",
|
||||
"hint": "HTTP/HTTPS proxy URL, e.g. http://127.0.0.1:7890. Applies only to this provider's API requests and does not affect Docker internal networking."
|
||||
},
|
||||
"model": {
|
||||
"description": "Model ID",
|
||||
"hint": "Model name, e.g., gpt-4o-mini, deepseek-chat."
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"handlersOperation": "Manage Handlers",
|
||||
"market": "AstrBot Plugin Market"
|
||||
},
|
||||
"titles": {
|
||||
"installedAstrBotPlugins": "Installed AstrBot Plugins"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search extensions...",
|
||||
"marketPlaceholder": "Search market extensions..."
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
"refresh": "Refresh",
|
||||
"save": "Save",
|
||||
"add": "Add SubAgent",
|
||||
"delete": "Delete"
|
||||
"delete": "Delete",
|
||||
"close": "Close"
|
||||
},
|
||||
"switches": {
|
||||
"enable": "Enable SubAgent orchestration",
|
||||
"dedupe": "Deduplicate main LLM tools (hide tools duplicated by SubAgents)"
|
||||
"enableHint": "Enable sub-agent functionality",
|
||||
"dedupe": "Deduplicate main LLM tools (hide tools duplicated by SubAgents)",
|
||||
"dedupeHint": "Remove duplicate tools from main agent"
|
||||
},
|
||||
"description": {
|
||||
"disabled": "When off: SubAgent is disabled; the main LLM mounts tools via persona rules (all by default) and calls them directly.",
|
||||
@@ -29,7 +32,8 @@
|
||||
"transferPrefix": "transfer_to_{name}",
|
||||
"switchLabel": "Enable",
|
||||
"previewTitle": "Preview: handoff tool shown to the main LLM",
|
||||
"personaChip": "Persona: {id}"
|
||||
"personaChip": "Persona: {id}",
|
||||
"personaPreview": "PERSONA PREVIEW"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Agent name (used for transfer_to_{name})",
|
||||
@@ -49,6 +53,13 @@
|
||||
"nameDuplicate": "Duplicate SubAgent name: {name}",
|
||||
"personaMissing": "SubAgent {name} has no persona selected",
|
||||
"saveSuccess": "Saved successfully",
|
||||
"saveFailed": "Failed to save"
|
||||
"saveFailed": "Failed to save",
|
||||
"nameRequired": "Name is required",
|
||||
"namePattern": "Lowercase letters, numbers, underscore only"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No Agents Configured",
|
||||
"subtitle": "Add a new sub-agent to get started",
|
||||
"action": "Create First Agent"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,18 @@
|
||||
"guideStep2": "完成安装后重启 AstrBot。",
|
||||
"guideStep3": "如果你使用 Docker,请优先使用镜像更新方式。"
|
||||
},
|
||||
"desktopApp": {
|
||||
"title": "更新桌面应用",
|
||||
"message": "将检查并升级 AstrBot 桌面端程序。",
|
||||
"currentVersion": "当前版本:",
|
||||
"latestVersion": "最新版本:",
|
||||
"checking": "正在检查桌面应用更新...",
|
||||
"hasNewVersion": "发现新版本,可点击确认升级。",
|
||||
"isLatest": "已经是最新版本",
|
||||
"installing": "正在下载并安装更新,完成后将自动重启应用...",
|
||||
"checkFailed": "检查更新失败,请稍后重试。",
|
||||
"installFailed": "升级失败,请稍后重试。"
|
||||
},
|
||||
"dashboardUpdate": {
|
||||
"title": "单独更新管理面板到最新版本",
|
||||
"currentVersion": "当前版本",
|
||||
|
||||
@@ -254,6 +254,10 @@
|
||||
"show_tool_use_status": {
|
||||
"description": "输出函数调用状态"
|
||||
},
|
||||
"show_tool_call_result": {
|
||||
"description": "输出函数调用返回结果",
|
||||
"hint": "仅在启用“输出函数调用状态”时生效,且最多展示 70 个字符。"
|
||||
},
|
||||
"sanitize_context_by_modalities": {
|
||||
"description": "按模型能力清理历史上下文",
|
||||
"hint": "开启后,在每次请求 LLM 前会按当前模型提供商中所选择的模型能力删除对话中不支持的图片/工具调用结构(会改变模型看到的历史)"
|
||||
@@ -1085,6 +1089,12 @@
|
||||
"embedding_api_base": {
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"openai_embedding": {
|
||||
"hint": "OpenAI Embedding 会在请求时自动补上 /v1。"
|
||||
},
|
||||
"gemini_embedding": {
|
||||
"hint": "Gemini Embedding 无需手动添加 /v1beta。"
|
||||
},
|
||||
"volcengine_cluster": {
|
||||
"description": "火山引擎集群",
|
||||
"hint": "若使用语音复刻大模型,可选volcano_icl或volcano_icl_concurr,默认使用volcano_tts"
|
||||
@@ -1312,6 +1322,10 @@
|
||||
"api_base": {
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"proxy": {
|
||||
"description": "代理地址",
|
||||
"hint": "HTTP/HTTPS 代理地址,格式如 http://127.0.0.1:7890。仅对该提供商的 API 请求生效,不影响 Docker 内网通信。"
|
||||
},
|
||||
"model": {
|
||||
"description": "模型 ID",
|
||||
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。"
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"skills": "Skills",
|
||||
"handlersOperation": "管理行为"
|
||||
},
|
||||
"titles": {
|
||||
"installedAstrBotPlugins": "已安装的 AstrBot 插件"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索插件...",
|
||||
"marketPlaceholder": "搜索市场插件..."
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
"refresh": "刷新",
|
||||
"save": "保存",
|
||||
"add": "新增 SubAgent",
|
||||
"delete": "删除"
|
||||
"delete": "删除",
|
||||
"close": "关闭"
|
||||
},
|
||||
"switches": {
|
||||
"enable": "启用 SubAgent 编排",
|
||||
"dedupe": "主 LLM 去重重复工具(与 SubAgent 重叠的工具将被隐藏)"
|
||||
"enableHint": "启用子代理功能",
|
||||
"dedupe": "主 LLM 去重重复工具(与 SubAgent 重叠的工具将被隐藏)",
|
||||
"dedupeHint": "从主代理中移除重复工具"
|
||||
},
|
||||
"description": {
|
||||
"disabled": "不启动:SubAgent 关闭;主 LLM 按 persona 规则挂载工具(默认全部),并直接调用。",
|
||||
@@ -39,6 +42,7 @@
|
||||
"providerHint": "留空表示跟随全局默认 provider。",
|
||||
"personaLabel": "选择人格设定",
|
||||
"personaHint": "SubAgent 将直接继承所选 Persona 的系统设定与工具。在人格设定页管理和新建人格。",
|
||||
"personaPreview": "人格预览",
|
||||
"descriptionLabel": "对主 LLM 的描述(用于决定是否 handoff)",
|
||||
"descriptionHint": "这段会作为 transfer_to_* 工具的描述给主 LLM 看,建议简短明确。"
|
||||
},
|
||||
@@ -50,6 +54,13 @@
|
||||
"nameDuplicate": "SubAgent 名称重复:{name}",
|
||||
"personaMissing": "SubAgent {name} 未选择 Persona",
|
||||
"saveSuccess": "保存成功",
|
||||
"saveFailed": "保存失败"
|
||||
"saveFailed": "保存失败",
|
||||
"nameRequired": "名称必填",
|
||||
"namePattern": "仅支持小写字母、数字和下划线"
|
||||
},
|
||||
"empty": {
|
||||
"title": "未配置 SubAgent",
|
||||
"subtitle": "添加一个新的子代理以开始",
|
||||
"action": "创建第一个 Agent"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,24 +50,27 @@ let installLoading = ref(false);
|
||||
const isDesktopReleaseMode = ref(
|
||||
typeof window !== 'undefined' && !!window.astrbotDesktop?.isDesktop
|
||||
);
|
||||
const redirectConfirmDialog = ref(false);
|
||||
const pendingRedirectUrl = ref('');
|
||||
const resolvingReleaseTarget = ref(false);
|
||||
const DEFAULT_ASTRBOT_RELEASE_BASE_URL = 'https://github.com/AstrBotDevs/AstrBot/releases';
|
||||
const resolveReleaseBaseUrl = () => {
|
||||
const raw = import.meta.env.VITE_ASTRBOT_RELEASE_BASE_URL;
|
||||
// Keep upstream default on AstrBot releases; desktop distributors can override via env injection.
|
||||
const normalized = raw?.trim()?.replace(/\/+$/, '') || '';
|
||||
const withoutLatestSuffix = normalized.replace(/\/latest$/i, '');
|
||||
return withoutLatestSuffix || DEFAULT_ASTRBOT_RELEASE_BASE_URL;
|
||||
};
|
||||
const releaseBaseUrl = resolveReleaseBaseUrl();
|
||||
const getReleaseUrlByTag = (tag: string | null | undefined) => {
|
||||
const normalizedTag = (tag || '').trim();
|
||||
if (!normalizedTag || normalizedTag.toLowerCase() === 'latest') {
|
||||
return `${releaseBaseUrl}/latest`;
|
||||
const desktopUpdateDialog = ref(false);
|
||||
const desktopUpdateChecking = ref(false);
|
||||
const desktopUpdateInstalling = ref(false);
|
||||
const desktopUpdateHasNewVersion = ref(false);
|
||||
const desktopUpdateCurrentVersion = ref('-');
|
||||
const desktopUpdateLatestVersion = ref('-');
|
||||
const desktopUpdateStatus = ref('');
|
||||
|
||||
const getAppUpdaterBridge = (): AstrBotAppUpdaterBridge | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return `${releaseBaseUrl}/tag/${normalizedTag}`;
|
||||
const bridge = window.astrbotAppUpdater;
|
||||
if (
|
||||
bridge &&
|
||||
typeof bridge.checkForAppUpdate === 'function' &&
|
||||
typeof bridge.installAppUpdate === 'function'
|
||||
) {
|
||||
return bridge;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getSelectedGitHubProxy = () => {
|
||||
@@ -89,16 +92,6 @@ const releasesHeader = computed(() => [
|
||||
{ title: t('core.header.updateDialog.table.sourceUrl'), key: 'zipball_url' },
|
||||
{ title: t('core.header.updateDialog.table.actions'), key: 'switch' }
|
||||
]);
|
||||
const latestReleaseTag = computed(() => {
|
||||
const firstRelease = (releases.value as any[])?.[0];
|
||||
if (firstRelease?.tag_name) {
|
||||
return firstRelease.tag_name as string;
|
||||
}
|
||||
return hasNewVersion.value
|
||||
? t('core.header.updateDialog.redirectConfirm.latestLabel')
|
||||
: (botCurrVersion.value || '-');
|
||||
});
|
||||
|
||||
// Form validation
|
||||
const formValid = ref(true);
|
||||
const passwordRules = computed(() => [
|
||||
@@ -126,47 +119,88 @@ const accountEditStatus = ref({
|
||||
message: ''
|
||||
});
|
||||
|
||||
const open = (link: string) => {
|
||||
window.open(link, '_blank');
|
||||
};
|
||||
|
||||
function requestExternalRedirect(link: string) {
|
||||
pendingRedirectUrl.value = link;
|
||||
redirectConfirmDialog.value = true;
|
||||
function cancelDesktopUpdate() {
|
||||
if (desktopUpdateInstalling.value) {
|
||||
return;
|
||||
}
|
||||
desktopUpdateDialog.value = false;
|
||||
}
|
||||
|
||||
function cancelExternalRedirect() {
|
||||
redirectConfirmDialog.value = false;
|
||||
pendingRedirectUrl.value = '';
|
||||
}
|
||||
async function openDesktopUpdateDialog() {
|
||||
desktopUpdateDialog.value = true;
|
||||
desktopUpdateChecking.value = true;
|
||||
desktopUpdateInstalling.value = false;
|
||||
desktopUpdateHasNewVersion.value = false;
|
||||
desktopUpdateCurrentVersion.value = '-';
|
||||
desktopUpdateLatestVersion.value = '-';
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checking');
|
||||
|
||||
function confirmExternalRedirect() {
|
||||
const targetUrl = pendingRedirectUrl.value;
|
||||
cancelExternalRedirect();
|
||||
if (targetUrl) {
|
||||
open(targetUrl);
|
||||
const bridge = getAppUpdaterBridge();
|
||||
if (!bridge) {
|
||||
desktopUpdateChecking.value = false;
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checkFailed');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await bridge.checkForAppUpdate();
|
||||
if (!result?.ok) {
|
||||
desktopUpdateCurrentVersion.value = result?.currentVersion || '-';
|
||||
desktopUpdateLatestVersion.value =
|
||||
result?.latestVersion || result?.currentVersion || '-';
|
||||
desktopUpdateStatus.value =
|
||||
result?.reason || t('core.header.updateDialog.desktopApp.checkFailed');
|
||||
return;
|
||||
}
|
||||
|
||||
desktopUpdateCurrentVersion.value = result.currentVersion || '-';
|
||||
desktopUpdateLatestVersion.value =
|
||||
result.latestVersion || result.currentVersion || '-';
|
||||
desktopUpdateHasNewVersion.value = !!result.hasUpdate;
|
||||
desktopUpdateStatus.value = result.hasUpdate
|
||||
? t('core.header.updateDialog.desktopApp.hasNewVersion')
|
||||
: t('core.header.updateDialog.desktopApp.isLatest');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checkFailed');
|
||||
} finally {
|
||||
desktopUpdateChecking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const getReleaseUrlForDesktop = () => {
|
||||
const firstRelease = (releases.value as any[])?.[0];
|
||||
if (firstRelease?.tag_name) {
|
||||
return getReleaseUrlByTag(firstRelease.tag_name as string);
|
||||
async function confirmDesktopUpdate() {
|
||||
if (!desktopUpdateHasNewVersion.value || desktopUpdateInstalling.value) {
|
||||
return;
|
||||
}
|
||||
if (hasNewVersion.value) return getReleaseUrlByTag('latest');
|
||||
const tag = botCurrVersion.value?.startsWith('v') ? botCurrVersion.value : 'latest';
|
||||
return getReleaseUrlByTag(tag);
|
||||
};
|
||||
|
||||
const bridge = getAppUpdaterBridge();
|
||||
if (!bridge) {
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installFailed');
|
||||
return;
|
||||
}
|
||||
|
||||
desktopUpdateInstalling.value = true;
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installing');
|
||||
|
||||
try {
|
||||
const result = await bridge.installAppUpdate();
|
||||
if (result?.ok) {
|
||||
desktopUpdateDialog.value = false;
|
||||
return;
|
||||
}
|
||||
desktopUpdateStatus.value =
|
||||
result?.reason || t('core.header.updateDialog.desktopApp.installFailed');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installFailed');
|
||||
} finally {
|
||||
desktopUpdateInstalling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateClick() {
|
||||
if (isDesktopReleaseMode.value) {
|
||||
requestExternalRedirect('');
|
||||
resolvingReleaseTarget.value = true;
|
||||
checkUpdate();
|
||||
void getReleases().finally(() => {
|
||||
pendingRedirectUrl.value = getReleaseUrlForDesktop() || getReleaseUrlByTag('latest');
|
||||
resolvingReleaseTarget.value = false;
|
||||
});
|
||||
void openDesktopUpdateDialog();
|
||||
return;
|
||||
}
|
||||
checkUpdate();
|
||||
@@ -680,40 +714,38 @@ onMounted(async () => {
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="redirectConfirmDialog" max-width="460">
|
||||
<v-dialog v-model="desktopUpdateDialog" max-width="460">
|
||||
<v-card>
|
||||
<v-card-title class="text-h3 pa-4 pl-6 pb-0">
|
||||
{{ t('core.header.updateDialog.redirectConfirm.title') }}
|
||||
{{ t('core.header.updateDialog.desktopApp.title') }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="mb-3">
|
||||
{{ t('core.header.updateDialog.redirectConfirm.message') }}
|
||||
{{ t('core.header.updateDialog.desktopApp.message') }}
|
||||
</div>
|
||||
<v-alert type="info" variant="tonal" density="compact">
|
||||
<div>
|
||||
{{ t('core.header.updateDialog.redirectConfirm.targetVersion') }}
|
||||
<strong v-if="!resolvingReleaseTarget">{{ latestReleaseTag }}</strong>
|
||||
<v-progress-circular v-else indeterminate size="16" width="2" class="ml-1" />
|
||||
{{ t('core.header.updateDialog.desktopApp.currentVersion') }}
|
||||
<strong>{{ desktopUpdateCurrentVersion }}</strong>
|
||||
</div>
|
||||
<div class="text-caption">
|
||||
{{ t('core.header.updateDialog.redirectConfirm.currentVersion') }}
|
||||
{{ botCurrVersion || '-' }}
|
||||
<div>
|
||||
{{ t('core.header.updateDialog.desktopApp.latestVersion') }}
|
||||
<strong v-if="!desktopUpdateChecking">{{ desktopUpdateLatestVersion }}</strong>
|
||||
<v-progress-circular v-else indeterminate size="16" width="2" class="ml-1" />
|
||||
</div>
|
||||
</v-alert>
|
||||
<div class="text-caption mt-3">
|
||||
<div>{{ t('core.header.updateDialog.redirectConfirm.guideTitle') }}</div>
|
||||
<div>1. {{ t('core.header.updateDialog.redirectConfirm.guideStep1') }}</div>
|
||||
<div>2. {{ t('core.header.updateDialog.redirectConfirm.guideStep2') }}</div>
|
||||
<div>3. {{ t('core.header.updateDialog.redirectConfirm.guideStep3') }}</div>
|
||||
{{ desktopUpdateStatus }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="cancelExternalRedirect">
|
||||
<v-btn color="grey" variant="text" @click="cancelDesktopUpdate" :disabled="desktopUpdateInstalling">
|
||||
{{ t('core.common.dialog.cancelButton') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="confirmExternalRedirect"
|
||||
:loading="resolvingReleaseTarget" :disabled="resolvingReleaseTarget || !pendingRedirectUrl">
|
||||
<v-btn color="primary" variant="flat" @click="confirmDesktopUpdate"
|
||||
:loading="desktopUpdateInstalling"
|
||||
:disabled="desktopUpdateChecking || desktopUpdateInstalling || !desktopUpdateHasNewVersion">
|
||||
{{ t('core.common.dialog.confirmButton') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
+19
@@ -1,7 +1,26 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface AstrBotDesktopAppUpdateCheckResult {
|
||||
ok: boolean;
|
||||
reason?: string | null;
|
||||
currentVersion?: string;
|
||||
latestVersion?: string | null;
|
||||
hasUpdate: boolean;
|
||||
}
|
||||
|
||||
interface AstrBotDesktopAppUpdateResult {
|
||||
ok: boolean;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
interface AstrBotAppUpdaterBridge {
|
||||
checkForAppUpdate: () => Promise<AstrBotDesktopAppUpdateCheckResult>;
|
||||
installAppUpdate: () => Promise<AstrBotDesktopAppUpdateResult>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
astrbotAppUpdater?: AstrBotAppUpdaterBridge;
|
||||
astrbotDesktop?: {
|
||||
isDesktop: boolean;
|
||||
isDesktopRuntime: () => Promise<boolean>;
|
||||
@@ -61,6 +61,7 @@ export function getTutorialLink(platformType) {
|
||||
"vocechat": "https://docs.astrbot.app/deploy/platform/vocechat.html",
|
||||
"satori": "https://docs.astrbot.app/deploy/platform/satori/llonebot.html",
|
||||
"misskey": "https://docs.astrbot.app/deploy/platform/misskey.html",
|
||||
"line": "https://docs.astrbot.app/deploy/platform/line.html",
|
||||
}
|
||||
return tutorialMap[platformType] || "https://docs.astrbot.app";
|
||||
}
|
||||
|
||||
+250
-2183
File diff suppressed because it is too large
Load Diff
@@ -333,12 +333,53 @@ const loadApiKeys = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const tryExecCommandCopy = (text) => {
|
||||
let textArea = null;
|
||||
try {
|
||||
if (typeof document === 'undefined' || !document.body) return false;
|
||||
textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.setAttribute('readonly', '');
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
textArea.style.pointerEvents = 'none';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
textArea.setSelectionRange(0, text.length);
|
||||
return document.execCommand('copy');
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
if (textArea?.parentNode) {
|
||||
textArea.parentNode.removeChild(textArea);
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyTextToClipboard = async (text) => {
|
||||
if (!text) return false;
|
||||
if (tryExecCommandCopy(text)) return true;
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) return false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyCreatedApiKey = async () => {
|
||||
if (!createdApiKeyPlaintext.value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdApiKeyPlaintext.value);
|
||||
const ok = await copyTextToClipboard(createdApiKeyPlaintext.value);
|
||||
if (ok) {
|
||||
showToast(tm('apiKey.messages.copySuccess'), 'success');
|
||||
} catch (_) {
|
||||
} else {
|
||||
showToast(tm('apiKey.messages.copyFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<template #label>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="text-body-2 font-weight-medium">{{ tm('switches.enable') }}</span>
|
||||
<span class="text-caption text-medium-emphasis">Enable sub-agent functionality</span>
|
||||
<span class="text-caption text-medium-emphasis">{{ tm('switches.enableHint') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</v-switch>
|
||||
@@ -80,7 +80,7 @@
|
||||
<template #label>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="text-body-2 font-weight-medium">{{ tm('switches.dedupe') }}</span>
|
||||
<span class="text-caption text-medium-emphasis">Remove duplicate tools from main agent</span>
|
||||
<span class="text-caption text-medium-emphasis">{{ tm('switches.dedupeHint') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</v-switch>
|
||||
@@ -166,7 +166,7 @@
|
||||
<v-text-field
|
||||
v-model="agent.name"
|
||||
:label="tm('form.nameLabel')"
|
||||
:rules="[v => !!v || 'Name is required', v => /^[a-z][a-z0-9_]*$/.test(v) || 'Lowercase letters, numbers, underscore only']"
|
||||
:rules="[v => !!v || tm('messages.nameRequired'), v => /^[a-z][a-z0-9_]*$/.test(v) || tm('messages.namePattern')]"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
@@ -215,7 +215,7 @@
|
||||
<v-col cols="12" md="6">
|
||||
<div class="h-100">
|
||||
<div class="text-caption font-weight-bold text-medium-emphasis mb-2 ml-1">
|
||||
PERSONA PREVIEW
|
||||
{{ tm('cards.personaPreview') }}
|
||||
</div>
|
||||
<PersonaQuickPreview
|
||||
:model-value="agent.persona_id"
|
||||
@@ -231,17 +231,17 @@
|
||||
<!-- Empty State -->
|
||||
<div v-if="cfg.agents.length === 0" class="d-flex flex-column align-center justify-center py-12 text-medium-emphasis">
|
||||
<v-icon icon="mdi-robot-off" size="64" class="mb-4 opacity-50" />
|
||||
<div class="text-h6">No Agents Configured</div>
|
||||
<div class="text-body-2 mb-4">Add a new sub-agent to get started</div>
|
||||
<div class="text-h6">{{ tm('empty.title') }}</div>
|
||||
<div class="text-body-2 mb-4">{{ tm('empty.subtitle') }}</div>
|
||||
<v-btn color="primary" variant="tonal" @click="addAgent">
|
||||
Create First Agent
|
||||
{{ tm('empty.action') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3000" location="top">
|
||||
{{ snackbar.message }}
|
||||
<template #actions>
|
||||
<v-btn variant="text" @click="snackbar.show = false">Close</v-btn>
|
||||
<v-btn variant="text" @click="snackbar.show = false">{{ tm('actions.close') }}</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
<script setup>
|
||||
import ExtensionCard from "@/components/shared/ExtensionCard.vue";
|
||||
import StyledMenu from "@/components/shared/StyledMenu.vue";
|
||||
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
|
||||
|
||||
const props = defineProps({
|
||||
state: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
commonStore,
|
||||
t,
|
||||
tm,
|
||||
router,
|
||||
route,
|
||||
getSelectedGitHubProxy,
|
||||
conflictDialog,
|
||||
checkAndPromptConflicts,
|
||||
handleConflictConfirm,
|
||||
fileInput,
|
||||
activeTab,
|
||||
validTabs,
|
||||
isValidTab,
|
||||
getLocationHash,
|
||||
extractTabFromHash,
|
||||
syncTabFromHash,
|
||||
extension_data,
|
||||
getInitialShowReserved,
|
||||
showReserved,
|
||||
snack_message,
|
||||
snack_show,
|
||||
snack_success,
|
||||
configDialog,
|
||||
extension_config,
|
||||
pluginMarketData,
|
||||
loadingDialog,
|
||||
showPluginInfoDialog,
|
||||
selectedPlugin,
|
||||
curr_namespace,
|
||||
updatingAll,
|
||||
readmeDialog,
|
||||
forceUpdateDialog,
|
||||
updateAllConfirmDialog,
|
||||
changelogDialog,
|
||||
getInitialListViewMode,
|
||||
isListView,
|
||||
pluginSearch,
|
||||
loading_,
|
||||
currentPage,
|
||||
dangerConfirmDialog,
|
||||
selectedDangerPlugin,
|
||||
selectedMarketInstallPlugin,
|
||||
installCompat,
|
||||
versionCompatibilityDialog,
|
||||
showUninstallDialog,
|
||||
pluginToUninstall,
|
||||
showSourceDialog,
|
||||
showSourceManagerDialog,
|
||||
sourceName,
|
||||
sourceUrl,
|
||||
customSources,
|
||||
selectedSource,
|
||||
showRemoveSourceDialog,
|
||||
sourceToRemove,
|
||||
editingSource,
|
||||
originalSourceUrl,
|
||||
extension_url,
|
||||
dialog,
|
||||
upload_file,
|
||||
uploadTab,
|
||||
showPluginFullName,
|
||||
marketSearch,
|
||||
debouncedMarketSearch,
|
||||
refreshingMarket,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
randomPluginNames,
|
||||
normalizeStr,
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
marketCustomFilter,
|
||||
plugin_handler_info_headers,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
filteredPlugins,
|
||||
filteredMarketPlugins,
|
||||
sortedPlugins,
|
||||
RANDOM_PLUGINS_COUNT,
|
||||
randomPlugins,
|
||||
shufflePlugins,
|
||||
refreshRandomPlugins,
|
||||
displayItemsPerPage,
|
||||
totalPages,
|
||||
paginatedPlugins,
|
||||
updatableExtensions,
|
||||
toggleShowReserved,
|
||||
toast,
|
||||
resetLoadingDialog,
|
||||
onLoadingDialogResult,
|
||||
failedPluginsDict,
|
||||
getExtensions,
|
||||
handleReloadAllFailed,
|
||||
checkUpdate,
|
||||
uninstallExtension,
|
||||
handleUninstallConfirm,
|
||||
updateExtension,
|
||||
showUpdateAllConfirm,
|
||||
confirmUpdateAll,
|
||||
cancelUpdateAll,
|
||||
confirmForceUpdate,
|
||||
updateAllExtensions,
|
||||
pluginOn,
|
||||
pluginOff,
|
||||
openExtensionConfig,
|
||||
updateConfig,
|
||||
showPluginInfo,
|
||||
reloadPlugin,
|
||||
viewReadme,
|
||||
viewChangelog,
|
||||
handleInstallPlugin,
|
||||
confirmDangerInstall,
|
||||
cancelDangerInstall,
|
||||
loadCustomSources,
|
||||
saveCustomSources,
|
||||
addCustomSource,
|
||||
openSourceManagerDialog,
|
||||
selectPluginSource,
|
||||
sourceSelectItems,
|
||||
editCustomSource,
|
||||
removeCustomSource,
|
||||
confirmRemoveSource,
|
||||
saveCustomSource,
|
||||
trimExtensionName,
|
||||
checkAlreadyInstalled,
|
||||
showVersionCompatibilityWarning,
|
||||
continueInstallIgnoringVersionWarning,
|
||||
cancelInstallOnVersionWarning,
|
||||
newExtension,
|
||||
normalizePlatformList,
|
||||
getPlatformDisplayList,
|
||||
resolveSelectedInstallPlugin,
|
||||
selectedInstallPlugin,
|
||||
checkInstallCompatibility,
|
||||
refreshPluginMarket,
|
||||
handleLocaleChange,
|
||||
searchDebounceTimer,
|
||||
} = props.state;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-tab-item v-show="activeTab === 'installed'">
|
||||
<div class="mb-4 pt-4 pb-4">
|
||||
<div class="d-flex align-center flex-wrap" style="gap: 12px">
|
||||
<h2 class="text-h2 mb-0">{{ tm("titles.installedAstrBotPlugins") }}</h2>
|
||||
|
||||
<div class="d-flex align-center flex-wrap ml-auto" style="gap: 8px">
|
||||
<v-text-field
|
||||
v-model="pluginSearch"
|
||||
density="compact"
|
||||
:label="tm('search.placeholder')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
hide-details
|
||||
single-line
|
||||
style="min-width: 220px; max-width: 340px"
|
||||
>
|
||||
</v-text-field>
|
||||
|
||||
<v-btn-toggle
|
||||
v-model="isListView"
|
||||
mandatory
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="view-mode-toggle"
|
||||
>
|
||||
<v-btn :value="false" icon="mdi-view-grid"></v-btn>
|
||||
<v-btn :value="true" icon="mdi-view-list"></v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" class="d-flex align-center flex-wrap ga-2">
|
||||
<v-btn variant="tonal" @click="toggleShowReserved">
|
||||
<v-icon>{{
|
||||
showReserved ? "mdi-eye-off" : "mdi-eye"
|
||||
}}</v-icon>
|
||||
{{
|
||||
showReserved
|
||||
? tm("buttons.hideSystemPlugins")
|
||||
: tm("buttons.showSystemPlugins")
|
||||
}}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
class="ml-2"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
:disabled="updatableExtensions.length === 0"
|
||||
:loading="updatingAll"
|
||||
@click="showUpdateAllConfirm"
|
||||
>
|
||||
<v-icon>mdi-update</v-icon>
|
||||
{{ tm("buttons.updateAll") }}
|
||||
</v-btn>
|
||||
|
||||
<v-dialog max-width="500px" v-if="extension_data.message">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
size="small"
|
||||
color="error"
|
||||
class="ml-auto"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon>mdi-alert-circle</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:default="{ isActive }">
|
||||
<v-card class="rounded-lg">
|
||||
<v-card-title class="headline d-flex align-center">
|
||||
<v-icon color="error" class="mr-2"
|
||||
>mdi-alert-circle</v-icon
|
||||
>
|
||||
{{ tm("dialogs.error.title") }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-1">
|
||||
{{ extension_data.message }}
|
||||
</p>
|
||||
<p class="text-caption mt-2">
|
||||
{{ tm("dialogs.error.checkConsole") }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="handleReloadAllFailed"
|
||||
>
|
||||
尝试一键重载修复
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="isActive.value = false"
|
||||
>{{ tm("buttons.close") }}</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-dialog>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-fade-transition hide-on-leave>
|
||||
<!-- 表格视图 -->
|
||||
<div v-if="isListView">
|
||||
<v-card class="rounded-lg overflow-hidden elevation-0">
|
||||
<v-data-table
|
||||
:headers="pluginHeaders"
|
||||
:items="filteredPlugins"
|
||||
:loading="loading_"
|
||||
item-key="name"
|
||||
hover
|
||||
>
|
||||
<template v-slot:loader>
|
||||
<v-row class="py-8 d-flex align-center justify-center">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
></v-progress-circular>
|
||||
<span class="ml-2">{{ tm("status.loading") }}</span>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.name="{ item }">
|
||||
<div class="d-flex align-center py-2">
|
||||
<div
|
||||
v-if="item.logo"
|
||||
class="mr-3"
|
||||
style="flex-shrink: 0"
|
||||
>
|
||||
<img
|
||||
:src="item.logo"
|
||||
:alt="item.name"
|
||||
style="
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="mr-3" style="flex-shrink: 0">
|
||||
<img
|
||||
:src="defaultPluginIcon"
|
||||
:alt="item.name"
|
||||
style="
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-h5" style="font-family: inherit;">
|
||||
{{
|
||||
item.display_name && item.display_name.length
|
||||
? item.display_name
|
||||
: item.name
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.display_name && item.display_name.length"
|
||||
class="text-caption text-medium-emphasis mt-1"
|
||||
>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.reserved"
|
||||
class="d-flex align-center mt-1"
|
||||
>
|
||||
<v-chip
|
||||
color="primary"
|
||||
size="x-small"
|
||||
class="font-weight-medium"
|
||||
>{{ tm("status.system") }}</v-chip
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.desc="{ item }">
|
||||
<div class="py-2">
|
||||
<div
|
||||
class="text-body-2 text-medium-emphasis"
|
||||
style="
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
"
|
||||
>
|
||||
{{ item.desc }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.support_platforms?.length"
|
||||
class="d-flex align-center flex-wrap mt-2"
|
||||
>
|
||||
<span class="text-caption text-medium-emphasis mr-2">
|
||||
{{ tm("card.status.supportPlatform") }}:
|
||||
</span>
|
||||
<v-chip
|
||||
v-for="platformId in item.support_platforms"
|
||||
:key="platformId"
|
||||
size="x-small"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ platformId }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.astrbot_version"
|
||||
class="d-flex align-center flex-wrap mt-1"
|
||||
>
|
||||
<span class="text-caption text-medium-emphasis mr-2">
|
||||
{{ tm("card.status.astrbotVersion") }}:
|
||||
</span>
|
||||
<v-chip
|
||||
size="x-small"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ item.astrbot_version }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.version="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<span class="text-body-2">{{ item.version }}</span>
|
||||
<v-icon
|
||||
v-if="item.has_update"
|
||||
color="warning"
|
||||
size="small"
|
||||
class="ml-1"
|
||||
>mdi-alert</v-icon
|
||||
>
|
||||
<v-tooltip v-if="item.has_update" activator="parent">
|
||||
<span
|
||||
>{{ tm("messages.hasUpdate") }}
|
||||
{{ item.online_version }}</span
|
||||
>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.author="{ item }">
|
||||
<div class="text-body-2">{{ item.author }}</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<div class="table-action-row d-flex align-center flex-nowrap ga-2 py-1">
|
||||
<v-btn
|
||||
v-if="!item.activated"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="success"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-play"
|
||||
@click="pluginOn(item)"
|
||||
>
|
||||
{{ tm("buttons.enable") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="error"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-pause"
|
||||
@click="pluginOff(item)"
|
||||
>
|
||||
{{ tm("buttons.disable") }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="reloadPlugin(item.name)"
|
||||
>
|
||||
{{ tm("buttons.reload") }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-cog"
|
||||
@click="openExtensionConfig(item.name)"
|
||||
>
|
||||
{{ tm("buttons.configure") }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-book-open-page-variant"
|
||||
:disabled="!item.repo"
|
||||
@click="item.repo && viewReadme(item)"
|
||||
>
|
||||
{{ tm("buttons.viewDocs") }}
|
||||
</v-btn>
|
||||
|
||||
<StyledMenu location="bottom end" offset="8">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon="mdi-dots-horizontal"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
class="table-action-btn"
|
||||
></v-btn>
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
prepend-icon="mdi-information"
|
||||
@click="showPluginInfo(item)"
|
||||
>
|
||||
<v-list-item-title>{{ tm("buttons.viewInfo") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
prepend-icon="mdi-update"
|
||||
@click="updateExtension(item.name)"
|
||||
>
|
||||
<v-list-item-title>{{ tm("buttons.update") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
prepend-icon="mdi-delete"
|
||||
:disabled="item.reserved"
|
||||
@click="uninstallExtension(item.name)"
|
||||
>
|
||||
<v-list-item-title>{{ tm("buttons.uninstall") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</StyledMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:no-data>
|
||||
<div class="text-center pa-8">
|
||||
<v-icon size="64" color="info" class="mb-4"
|
||||
>mdi-puzzle-outline</v-icon
|
||||
>
|
||||
<div class="text-h5 mb-2">
|
||||
{{ tm("empty.noPlugins") }}
|
||||
</div>
|
||||
<div class="text-body-1 mb-4">
|
||||
{{ tm("empty.noPluginsDesc") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- 卡片视图 -->
|
||||
<div v-else>
|
||||
<v-row v-if="filteredPlugins.length === 0" class="text-center">
|
||||
<v-col cols="12" class="pa-2">
|
||||
<v-icon size="64" color="info" class="mb-4"
|
||||
>mdi-puzzle-outline</v-icon
|
||||
>
|
||||
<div class="text-h5 mb-2">{{ tm("empty.noPlugins") }}</div>
|
||||
<div class="text-body-1 mb-4">
|
||||
{{ tm("empty.noPluginsDesc") }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
v-for="extension in filteredPlugins"
|
||||
:key="extension.name"
|
||||
class="pb-2"
|
||||
>
|
||||
<ExtensionCard
|
||||
:extension="extension"
|
||||
class="rounded-lg"
|
||||
style="background-color: rgb(var(--v-theme-mcpCardBg))"
|
||||
@configure="openExtensionConfig(extension.name)"
|
||||
@uninstall="
|
||||
(ext, options) => uninstallExtension(ext.name, options)
|
||||
"
|
||||
@update="updateExtension(extension.name)"
|
||||
@reload="reloadPlugin(extension.name)"
|
||||
@toggle-activation="
|
||||
extension.activated
|
||||
? pluginOff(extension)
|
||||
: pluginOn(extension)
|
||||
"
|
||||
@view-handlers="showPluginInfo(extension)"
|
||||
@view-readme="viewReadme(extension)"
|
||||
@view-changelog="viewChangelog(extension)"
|
||||
>
|
||||
</ExtensionCard>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-fade-transition>
|
||||
|
||||
<v-tooltip :text="tm('market.installPlugin')" location="left">
|
||||
<template v-slot:activator="{ props }">
|
||||
<button
|
||||
v-bind="props"
|
||||
type="button"
|
||||
class="v-btn v-btn--elevated v-btn--icon v-theme--PurpleThemeDark bg-darkprimary v-btn--density-default v-btn--size-x-large v-btn--variant-elevated fab-button"
|
||||
style="
|
||||
position: fixed;
|
||||
right: 52px;
|
||||
bottom: 52px;
|
||||
z-index: 10000;
|
||||
border-radius: 16px;
|
||||
"
|
||||
@click="dialog = true"
|
||||
>
|
||||
<span class="v-btn__overlay"></span>
|
||||
<span class="v-btn__underlay"></span>
|
||||
<span class="v-btn__content" data-no-activator="">
|
||||
<i
|
||||
class="mdi-plus mdi v-icon notranslate v-theme--PurpleThemeDark v-icon--size-default"
|
||||
aria-hidden="true"
|
||||
style="font-size: 32px"
|
||||
></i>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-tab-item>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view-mode-toggle :deep(.v-btn) {
|
||||
min-width: 30px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.table-action-btn {
|
||||
min-height: 34px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-action-row {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fab-button {
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.fab-button:hover {
|
||||
transform: translateY(-4px) scale(1.05);
|
||||
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,373 @@
|
||||
<script setup>
|
||||
import MarketPluginCard from "@/components/extension/MarketPluginCard.vue";
|
||||
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
state: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
commonStore,
|
||||
t,
|
||||
tm,
|
||||
router,
|
||||
route,
|
||||
getSelectedGitHubProxy,
|
||||
conflictDialog,
|
||||
checkAndPromptConflicts,
|
||||
handleConflictConfirm,
|
||||
fileInput,
|
||||
activeTab,
|
||||
validTabs,
|
||||
isValidTab,
|
||||
getLocationHash,
|
||||
extractTabFromHash,
|
||||
syncTabFromHash,
|
||||
extension_data,
|
||||
getInitialShowReserved,
|
||||
showReserved,
|
||||
snack_message,
|
||||
snack_show,
|
||||
snack_success,
|
||||
configDialog,
|
||||
extension_config,
|
||||
pluginMarketData,
|
||||
loadingDialog,
|
||||
showPluginInfoDialog,
|
||||
selectedPlugin,
|
||||
curr_namespace,
|
||||
updatingAll,
|
||||
readmeDialog,
|
||||
forceUpdateDialog,
|
||||
updateAllConfirmDialog,
|
||||
changelogDialog,
|
||||
getInitialListViewMode,
|
||||
isListView,
|
||||
pluginSearch,
|
||||
loading_,
|
||||
currentPage,
|
||||
dangerConfirmDialog,
|
||||
selectedDangerPlugin,
|
||||
selectedMarketInstallPlugin,
|
||||
installCompat,
|
||||
versionCompatibilityDialog,
|
||||
showUninstallDialog,
|
||||
pluginToUninstall,
|
||||
showSourceDialog,
|
||||
showSourceManagerDialog,
|
||||
sourceName,
|
||||
sourceUrl,
|
||||
customSources,
|
||||
selectedSource,
|
||||
showRemoveSourceDialog,
|
||||
sourceToRemove,
|
||||
editingSource,
|
||||
originalSourceUrl,
|
||||
extension_url,
|
||||
dialog,
|
||||
upload_file,
|
||||
uploadTab,
|
||||
showPluginFullName,
|
||||
marketSearch,
|
||||
debouncedMarketSearch,
|
||||
refreshingMarket,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
randomPluginNames,
|
||||
normalizeStr,
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
marketCustomFilter,
|
||||
plugin_handler_info_headers,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
filteredPlugins,
|
||||
filteredMarketPlugins,
|
||||
sortedPlugins,
|
||||
RANDOM_PLUGINS_COUNT,
|
||||
randomPlugins,
|
||||
shufflePlugins,
|
||||
refreshRandomPlugins,
|
||||
displayItemsPerPage,
|
||||
totalPages,
|
||||
paginatedPlugins,
|
||||
updatableExtensions,
|
||||
toggleShowReserved,
|
||||
toast,
|
||||
resetLoadingDialog,
|
||||
onLoadingDialogResult,
|
||||
failedPluginsDict,
|
||||
getExtensions,
|
||||
handleReloadAllFailed,
|
||||
checkUpdate,
|
||||
uninstallExtension,
|
||||
handleUninstallConfirm,
|
||||
updateExtension,
|
||||
showUpdateAllConfirm,
|
||||
confirmUpdateAll,
|
||||
cancelUpdateAll,
|
||||
confirmForceUpdate,
|
||||
updateAllExtensions,
|
||||
pluginOn,
|
||||
pluginOff,
|
||||
openExtensionConfig,
|
||||
updateConfig,
|
||||
showPluginInfo,
|
||||
reloadPlugin,
|
||||
viewReadme,
|
||||
viewChangelog,
|
||||
handleInstallPlugin,
|
||||
confirmDangerInstall,
|
||||
cancelDangerInstall,
|
||||
loadCustomSources,
|
||||
saveCustomSources,
|
||||
addCustomSource,
|
||||
openSourceManagerDialog,
|
||||
selectPluginSource,
|
||||
sourceSelectItems,
|
||||
editCustomSource,
|
||||
removeCustomSource,
|
||||
confirmRemoveSource,
|
||||
saveCustomSource,
|
||||
trimExtensionName,
|
||||
checkAlreadyInstalled,
|
||||
showVersionCompatibilityWarning,
|
||||
continueInstallIgnoringVersionWarning,
|
||||
cancelInstallOnVersionWarning,
|
||||
newExtension,
|
||||
normalizePlatformList,
|
||||
getPlatformDisplayList,
|
||||
resolveSelectedInstallPlugin,
|
||||
selectedInstallPlugin,
|
||||
checkInstallCompatibility,
|
||||
refreshPluginMarket,
|
||||
handleLocaleChange,
|
||||
searchDebounceTimer,
|
||||
} = props.state;
|
||||
|
||||
const currentSourceName = computed(() => {
|
||||
if (!selectedSource.value) {
|
||||
return tm("market.defaultSource");
|
||||
}
|
||||
const matched = customSources.value.find((s) => s.url === selectedSource.value);
|
||||
return matched?.name || tm("market.defaultSource");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-tab-item v-show="activeTab === 'market'">
|
||||
<div class="mb-6 pt-4 pb-4">
|
||||
<div class="d-flex align-center flex-wrap" style="gap: 12px">
|
||||
<h2 class="text-h2 mb-0">{{ tm("tabs.market") }}</h2>
|
||||
|
||||
<v-tooltip location="top" :text="tm('market.sourceManagement')">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
variant="tonal"
|
||||
rounded="md"
|
||||
color="primary"
|
||||
class="text-none px-2"
|
||||
@click="openSourceManagerDialog"
|
||||
>
|
||||
<v-icon size="18" class="mr-1">mdi-source-branch</v-icon>
|
||||
<span class="text-truncate" style="max-width: 180px">
|
||||
{{ currentSourceName }}
|
||||
</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-text-field
|
||||
v-model="marketSearch"
|
||||
density="compact"
|
||||
:label="tm('search.marketPlaceholder')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
hide-details
|
||||
single-line
|
||||
style="min-width: 220px; max-width: 340px"
|
||||
>
|
||||
</v-text-field>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="d-flex align-center text-caption text-medium-emphasis mt-2"
|
||||
style="color: grey; line-height: 1.4"
|
||||
>
|
||||
<v-icon size="16" class="mr-1">mdi-alert-outline</v-icon>
|
||||
<span>{{ tm("market.sourceSafetyWarning") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <small style="color: var(--v-theme-secondaryText);">每个插件都是作者无偿提供的的劳动成果。如果您喜欢某个插件,请 Star!</small> -->
|
||||
|
||||
<!-- FAB Button -->
|
||||
<v-tooltip :text="tm('market.installPlugin')" location="left">
|
||||
<template v-slot:activator="{ props }">
|
||||
<button
|
||||
v-bind="props"
|
||||
type="button"
|
||||
class="v-btn v-btn--elevated v-btn--icon v-theme--PurpleThemeDark bg-darkprimary v-btn--density-default v-btn--size-x-large v-btn--variant-elevated fab-button"
|
||||
style="
|
||||
position: fixed;
|
||||
right: 52px;
|
||||
bottom: 52px;
|
||||
z-index: 10000;
|
||||
border-radius: 16px;
|
||||
"
|
||||
@click="dialog = true"
|
||||
>
|
||||
<span class="v-btn__overlay"></span>
|
||||
<span class="v-btn__underlay"></span>
|
||||
<span class="v-btn__content" data-no-activator="">
|
||||
<i
|
||||
class="mdi-plus mdi v-icon notranslate v-theme--PurpleThemeDark v-icon--size-default"
|
||||
aria-hidden="true"
|
||||
style="font-size: 32px"
|
||||
></i>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<div class="mt-4">
|
||||
<div
|
||||
class="d-flex align-center mb-2"
|
||||
style="justify-content: space-between; flex-wrap: wrap; gap: 8px"
|
||||
>
|
||||
<h2>
|
||||
{{ tm("market.randomPlugins") }}
|
||||
</h2>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-shuffle-variant"
|
||||
:disabled="pluginMarketData.length === 0"
|
||||
@click="refreshRandomPlugins"
|
||||
>
|
||||
{{ tm("buttons.reshuffle") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-row class="mb-6" dense>
|
||||
<v-col
|
||||
v-for="plugin in randomPlugins"
|
||||
:key="`random-${plugin.name}`"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
class="pb-2"
|
||||
>
|
||||
<MarketPluginCard
|
||||
:plugin="plugin"
|
||||
:default-plugin-icon="defaultPluginIcon"
|
||||
:show-plugin-full-name="showPluginFullName"
|
||||
@install="handleInstallPlugin"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div
|
||||
class="d-flex align-center mb-2"
|
||||
style="
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<div class="d-flex align-center" style="gap: 6px">
|
||||
<h2>
|
||||
{{ tm("market.allPlugins") }}({{
|
||||
filteredMarketPlugins.length
|
||||
}})
|
||||
</h2>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
@click="refreshPluginMarket"
|
||||
:loading="refreshingMarket"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="d-flex align-center"
|
||||
style="gap: 8px; flex-wrap: wrap"
|
||||
>
|
||||
<v-select
|
||||
v-model="sortBy"
|
||||
:items="[
|
||||
{ title: tm('sort.default'), value: 'default' },
|
||||
{ title: tm('sort.stars'), value: 'stars' },
|
||||
{ title: tm('sort.author'), value: 'author' },
|
||||
{ title: tm('sort.updated'), value: 'updated' },
|
||||
]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
style="max-width: 150px"
|
||||
>
|
||||
<template v-slot:prepend-inner>
|
||||
<v-icon size="small">mdi-sort</v-icon>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
v-if="sortBy !== 'default'"
|
||||
@click="sortOrder = sortOrder === 'desc' ? 'asc' : 'desc'"
|
||||
variant="text"
|
||||
density="compact"
|
||||
>
|
||||
<v-icon>{{
|
||||
sortOrder === "desc"
|
||||
? "mdi-sort-descending"
|
||||
: "mdi-sort-ascending"
|
||||
}}</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{
|
||||
sortOrder === "desc"
|
||||
? tm("sort.descending")
|
||||
: tm("sort.ascending")
|
||||
}}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row style="min-height: 26rem" dense>
|
||||
<v-col
|
||||
v-for="plugin in paginatedPlugins"
|
||||
:key="plugin.name"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
class="pb-2"
|
||||
>
|
||||
<MarketPluginCard
|
||||
:plugin="plugin"
|
||||
:default-plugin-icon="defaultPluginIcon"
|
||||
:show-plugin-full-name="showPluginFullName"
|
||||
@install="handleInstallPlugin"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div class="d-flex justify-center mt-4" v-if="totalPages > 1">
|
||||
<v-pagination
|
||||
v-model="currentPage"
|
||||
:length="totalPages"
|
||||
:total-visible="7"
|
||||
size="small"
|
||||
></v-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</v-tab-item>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.18.1"
|
||||
version = "4.18.3"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
AstrBot 测试配置
|
||||
|
||||
提供共享的 pytest fixtures 和测试工具。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from asyncio import Queue
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
# 使用 tests/fixtures/helpers.py 中的共享工具函数,避免重复定义
|
||||
from tests.fixtures.helpers import create_mock_llm_response, create_mock_message_component
|
||||
|
||||
# 将项目根目录添加到 sys.path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# 设置测试环境变量
|
||||
os.environ.setdefault("TESTING", "true")
|
||||
os.environ.setdefault("ASTRBOT_TEST_MODE", "true")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 测试收集和排序
|
||||
# ============================================================
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(session, config, items): # noqa: ARG001
|
||||
"""重新排序测试:单元测试优先,集成测试在后。"""
|
||||
unit_tests = []
|
||||
integration_tests = []
|
||||
deselected = []
|
||||
profile = config.getoption("--test-profile") or os.environ.get(
|
||||
"ASTRBOT_TEST_PROFILE", "all"
|
||||
)
|
||||
|
||||
for item in items:
|
||||
item_path = Path(str(item.path))
|
||||
is_integration = "integration" in item_path.parts
|
||||
|
||||
if is_integration:
|
||||
if item.get_closest_marker("integration") is None:
|
||||
item.add_marker(pytest.mark.integration)
|
||||
item.add_marker(pytest.mark.tier_d)
|
||||
integration_tests.append(item)
|
||||
else:
|
||||
if item.get_closest_marker("unit") is None:
|
||||
item.add_marker(pytest.mark.unit)
|
||||
if any(
|
||||
item.get_closest_marker(marker) is not None
|
||||
for marker in ("platform", "provider", "slow")
|
||||
):
|
||||
item.add_marker(pytest.mark.tier_c)
|
||||
unit_tests.append(item)
|
||||
|
||||
# 单元测试 -> 集成测试
|
||||
ordered_items = unit_tests + integration_tests
|
||||
if profile == "blocking":
|
||||
selected_items = []
|
||||
for item in ordered_items:
|
||||
if item.get_closest_marker("tier_c") or item.get_closest_marker("tier_d"):
|
||||
deselected.append(item)
|
||||
else:
|
||||
selected_items.append(item)
|
||||
if deselected:
|
||||
config.hook.pytest_deselected(items=deselected)
|
||||
items[:] = selected_items
|
||||
return
|
||||
|
||||
items[:] = ordered_items
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
"""增加测试执行档位选择。"""
|
||||
parser.addoption(
|
||||
"--test-profile",
|
||||
action="store",
|
||||
default=None,
|
||||
choices=["all", "blocking"],
|
||||
help="Select test profile. 'blocking' excludes auto-classified tier_c/tier_d tests.",
|
||||
)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""注册自定义标记。"""
|
||||
config.addinivalue_line("markers", "unit: 单元测试")
|
||||
config.addinivalue_line("markers", "integration: 集成测试")
|
||||
config.addinivalue_line("markers", "slow: 慢速测试")
|
||||
config.addinivalue_line("markers", "platform: 平台适配器测试")
|
||||
config.addinivalue_line("markers", "provider: LLM Provider 测试")
|
||||
config.addinivalue_line("markers", "db: 数据库相关测试")
|
||||
config.addinivalue_line("markers", "tier_c: C-tier tests (optional / non-blocking)")
|
||||
config.addinivalue_line("markers", "tier_d: D-tier tests (extended / integration)")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 临时目录和文件 Fixtures
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(tmp_path: Path) -> Path:
|
||||
"""创建临时目录用于测试。"""
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_queue() -> Queue:
|
||||
"""Create a shared asyncio queue fixture for tests."""
|
||||
return Queue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platform_settings() -> dict:
|
||||
"""Create a shared empty platform settings fixture for adapter tests."""
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_data_dir(temp_dir: Path) -> Path:
|
||||
"""创建模拟的 data 目录结构。"""
|
||||
data_dir = temp_dir / "data"
|
||||
data_dir.mkdir()
|
||||
|
||||
# 创建必要的子目录
|
||||
(data_dir / "config").mkdir()
|
||||
(data_dir / "plugins").mkdir()
|
||||
(data_dir / "temp").mkdir()
|
||||
(data_dir / "attachments").mkdir()
|
||||
|
||||
return data_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_config_file(temp_data_dir: Path) -> Path:
|
||||
"""创建临时配置文件。"""
|
||||
config_path = temp_data_dir / "config" / "cmd_config.json"
|
||||
default_config = {
|
||||
"provider": [],
|
||||
"platform": [],
|
||||
"provider_settings": {},
|
||||
"default_personality": None,
|
||||
"timezone": "Asia/Shanghai",
|
||||
}
|
||||
config_path.write_text(json.dumps(default_config, indent=2), encoding="utf-8")
|
||||
return config_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_file(temp_data_dir: Path) -> Path:
|
||||
"""创建临时数据库文件路径。"""
|
||||
return temp_data_dir / "test.db"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Mock Fixtures
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider():
|
||||
"""创建模拟的 Provider。"""
|
||||
provider = MagicMock()
|
||||
provider.provider_config = {
|
||||
"id": "test-provider",
|
||||
"type": "openai_chat_completion",
|
||||
"model": "gpt-4o-mini",
|
||||
}
|
||||
provider.get_model = MagicMock(return_value="gpt-4o-mini")
|
||||
provider.text_chat = AsyncMock()
|
||||
provider.text_chat_stream = AsyncMock()
|
||||
provider.terminate = AsyncMock()
|
||||
return provider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_platform():
|
||||
"""创建模拟的 Platform。"""
|
||||
platform = MagicMock()
|
||||
platform.platform_name = "test_platform"
|
||||
platform.platform_meta = MagicMock()
|
||||
platform.platform_meta.support_proactive_message = False
|
||||
platform.send_message = AsyncMock()
|
||||
platform.terminate = AsyncMock()
|
||||
return platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conversation():
|
||||
"""创建模拟的 Conversation。"""
|
||||
from astrbot.core.db.po import ConversationV2
|
||||
|
||||
return ConversationV2(
|
||||
conversation_id="test-conv-id",
|
||||
platform_id="test_platform",
|
||||
user_id="test_user",
|
||||
content=[],
|
||||
persona_id=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event():
|
||||
"""创建模拟的 AstrMessageEvent。"""
|
||||
event = MagicMock()
|
||||
event.unified_msg_origin = "test_umo"
|
||||
event.session_id = "test_session"
|
||||
event.message_str = "Hello, world!"
|
||||
event.message_obj = MagicMock()
|
||||
event.message_obj.message = []
|
||||
event.message_obj.sender = MagicMock()
|
||||
event.message_obj.sender.user_id = "test_user"
|
||||
event.message_obj.sender.nickname = "Test User"
|
||||
event.message_obj.group_id = None
|
||||
event.message_obj.group = None
|
||||
event.get_platform_name = MagicMock(return_value="test_platform")
|
||||
event.get_platform_id = MagicMock(return_value="test_platform")
|
||||
event.get_group_id = MagicMock(return_value=None)
|
||||
event.get_extra = MagicMock(return_value=None)
|
||||
event.set_extra = MagicMock()
|
||||
event.trace = MagicMock()
|
||||
event.platform_meta = MagicMock()
|
||||
event.platform_meta.support_proactive_message = False
|
||||
return event
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 配置 Fixtures
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def astrbot_config(temp_config_file: Path):
|
||||
"""创建 AstrBotConfig 实例。"""
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
|
||||
config = AstrBotConfig()
|
||||
config._config_path = str(temp_config_file) # noqa: SLF001
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main_agent_build_config():
|
||||
"""创建 MainAgentBuildConfig 实例。"""
|
||||
from astrbot.core.astr_main_agent import MainAgentBuildConfig
|
||||
|
||||
return MainAgentBuildConfig(
|
||||
tool_call_timeout=60,
|
||||
tool_schema_mode="full",
|
||||
provider_wake_prefix="",
|
||||
streaming_response=True,
|
||||
sanitize_context_by_modalities=False,
|
||||
kb_agentic_mode=False,
|
||||
file_extract_enabled=False,
|
||||
context_limit_reached_strategy="truncate_by_turns",
|
||||
llm_safety_mode=True,
|
||||
computer_use_runtime="local",
|
||||
add_cron_tools=True,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 数据库 Fixtures
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def temp_db(temp_db_file: Path):
|
||||
"""创建临时数据库实例。"""
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
|
||||
db = SQLiteDatabase(str(temp_db_file))
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
await db.engine.dispose()
|
||||
if temp_db_file.exists():
|
||||
temp_db_file.unlink()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Context Fixtures
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mock_context(
|
||||
astrbot_config,
|
||||
temp_db,
|
||||
mock_provider,
|
||||
mock_platform,
|
||||
):
|
||||
"""创建模拟的插件上下文。"""
|
||||
from asyncio import Queue
|
||||
|
||||
from astrbot.core.star.context import Context
|
||||
|
||||
event_queue = Queue()
|
||||
|
||||
provider_manager = MagicMock()
|
||||
provider_manager.get_using_provider = MagicMock(return_value=mock_provider)
|
||||
provider_manager.get_provider_by_id = MagicMock(return_value=mock_provider)
|
||||
|
||||
platform_manager = MagicMock()
|
||||
conversation_manager = MagicMock()
|
||||
message_history_manager = MagicMock()
|
||||
persona_manager = MagicMock()
|
||||
persona_manager.personas_v3 = []
|
||||
astrbot_config_mgr = MagicMock()
|
||||
knowledge_base_manager = MagicMock()
|
||||
cron_manager = MagicMock()
|
||||
subagent_orchestrator = None
|
||||
|
||||
context = Context(
|
||||
event_queue,
|
||||
astrbot_config,
|
||||
temp_db,
|
||||
provider_manager,
|
||||
platform_manager,
|
||||
conversation_manager,
|
||||
message_history_manager,
|
||||
persona_manager,
|
||||
astrbot_config_mgr,
|
||||
knowledge_base_manager,
|
||||
cron_manager,
|
||||
subagent_orchestrator,
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Provider Request Fixtures
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider_request():
|
||||
"""创建 ProviderRequest 实例。"""
|
||||
from astrbot.core.provider.entities import ProviderRequest
|
||||
|
||||
return ProviderRequest(
|
||||
prompt="Hello",
|
||||
session_id="test_session",
|
||||
image_urls=[],
|
||||
contexts=[],
|
||||
system_prompt="You are a helpful assistant.",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 跳过条件
|
||||
# ============================================================
|
||||
|
||||
|
||||
def pytest_runtest_setup(item):
|
||||
"""在测试运行前检查跳过条件。"""
|
||||
# 跳过需要 API Key 但未设置的 Provider 测试
|
||||
if item.get_closest_marker("provider"):
|
||||
if not os.environ.get("TEST_PROVIDER_API_KEY"):
|
||||
pytest.skip("TEST_PROVIDER_API_KEY not set")
|
||||
|
||||
# 跳过需要特定平台的测试
|
||||
if item.get_closest_marker("platform"):
|
||||
required_platform = None
|
||||
marker = item.get_closest_marker("platform")
|
||||
if marker and marker.args:
|
||||
required_platform = marker.args[0]
|
||||
|
||||
if required_platform and not os.environ.get(
|
||||
f"TEST_{required_platform.upper()}_ENABLED"
|
||||
):
|
||||
pytest.skip(f"TEST_{required_platform.upper()}_ENABLED not set")
|
||||
Vendored
+64
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
AstrBot 测试数据
|
||||
|
||||
此目录存放测试用的静态数据和配置文件。
|
||||
|
||||
目录结构:
|
||||
- fixtures/
|
||||
├── configs/ # 测试配置文件
|
||||
├── messages/ # 测试消息数据
|
||||
├── plugins/ # 测试插件
|
||||
├── knowledge_base/ # 测试知识库数据
|
||||
├── mocks/ # Mock 模块
|
||||
└── helpers.py # 辅助函数
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .helpers import (
|
||||
NoopAwaitable,
|
||||
create_mock_discord_attachment,
|
||||
create_mock_discord_channel,
|
||||
create_mock_discord_user,
|
||||
create_mock_file,
|
||||
create_mock_llm_response,
|
||||
create_mock_message_component,
|
||||
create_mock_update,
|
||||
make_platform_config,
|
||||
)
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def load_fixture(filename: str) -> dict:
|
||||
"""加载 JSON 格式的测试数据。"""
|
||||
filepath = FIXTURES_DIR / filename
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(f"Fixture not found: {filepath}")
|
||||
return json.loads(filepath.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def get_fixture_path(filename: str) -> Path:
|
||||
"""获取测试数据文件路径。"""
|
||||
filepath = FIXTURES_DIR / filename
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(f"Fixture not found: {filepath}")
|
||||
return filepath
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FIXTURES_DIR",
|
||||
"load_fixture",
|
||||
"get_fixture_path",
|
||||
# 辅助函数
|
||||
"NoopAwaitable",
|
||||
"make_platform_config",
|
||||
"create_mock_update",
|
||||
"create_mock_file",
|
||||
"create_mock_discord_attachment",
|
||||
"create_mock_discord_user",
|
||||
"create_mock_discord_channel",
|
||||
"create_mock_message_component",
|
||||
"create_mock_llm_response",
|
||||
]
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"provider": [
|
||||
{
|
||||
"id": "test-openai",
|
||||
"type": "openai_chat_completion",
|
||||
"model": "gpt-4o-mini",
|
||||
"key": ["test-key"]
|
||||
}
|
||||
],
|
||||
"platform": [],
|
||||
"provider_settings": {
|
||||
"default_personality": null,
|
||||
"prompt_prefix": "",
|
||||
"image_caption_provider_id": "",
|
||||
"datetime_system_prompt": true,
|
||||
"identifier": true,
|
||||
"group_name_display": true
|
||||
},
|
||||
"default_personality": null,
|
||||
"timezone": "Asia/Shanghai"
|
||||
}
|
||||
Vendored
+332
@@ -0,0 +1,332 @@
|
||||
"""测试辅助函数和工具类。
|
||||
|
||||
提供统一的测试辅助工具,减少测试代码重复。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from astrbot.core.message.components import BaseMessageComponent
|
||||
|
||||
|
||||
class NoopAwaitable:
|
||||
"""可等待的空操作对象。
|
||||
|
||||
用于 mock 需要返回 awaitable 对象的方法。
|
||||
"""
|
||||
|
||||
def __await__(self):
|
||||
if False:
|
||||
yield
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 平台配置工厂
|
||||
# ============================================================
|
||||
|
||||
|
||||
def make_platform_config(platform_type: str, **kwargs) -> dict:
|
||||
"""平台配置工厂函数。
|
||||
|
||||
Args:
|
||||
platform_type: 平台类型 (telegram, discord, aiocqhttp 等)
|
||||
**kwargs: 覆盖默认配置的字段
|
||||
|
||||
Returns:
|
||||
dict: 平台配置字典
|
||||
"""
|
||||
configs = {
|
||||
"telegram": {
|
||||
"id": "test_telegram",
|
||||
"telegram_token": "test_token_123",
|
||||
"telegram_api_base_url": "https://api.telegram.org/bot",
|
||||
"telegram_file_base_url": "https://api.telegram.org/file/bot",
|
||||
"telegram_command_register": True,
|
||||
"telegram_command_auto_refresh": True,
|
||||
"telegram_command_register_interval": 300,
|
||||
"telegram_media_group_timeout": 2.5,
|
||||
"telegram_media_group_max_wait": 10.0,
|
||||
"start_message": "Welcome to AstrBot!",
|
||||
},
|
||||
"discord": {
|
||||
"id": "test_discord",
|
||||
"discord_token": "test_token_123",
|
||||
"discord_proxy": None,
|
||||
"discord_command_register": True,
|
||||
"discord_guild_id_for_debug": None,
|
||||
"discord_activity_name": "Playing AstrBot",
|
||||
},
|
||||
"aiocqhttp": {
|
||||
"id": "test_aiocqhttp",
|
||||
"ws_reverse_host": "0.0.0.0",
|
||||
"ws_reverse_port": 6199,
|
||||
"ws_reverse_token": "test_token",
|
||||
},
|
||||
"webchat": {
|
||||
"id": "test_webchat",
|
||||
},
|
||||
"wecom": {
|
||||
"id": "test_wecom",
|
||||
"wecom_corpid": "test_corpid",
|
||||
"wecom_secret": "test_secret",
|
||||
},
|
||||
}
|
||||
config = configs.get(platform_type, {"id": f"test_{platform_type}"}).copy()
|
||||
config.update(kwargs)
|
||||
return config
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Telegram 辅助函数
|
||||
# ============================================================
|
||||
|
||||
|
||||
def create_mock_update(
|
||||
message_text: str | None = "Hello World",
|
||||
chat_type: str = "private",
|
||||
chat_id: int = 123456789,
|
||||
user_id: int = 987654321,
|
||||
username: str = "test_user",
|
||||
message_id: int = 1,
|
||||
media_group_id: str | None = None,
|
||||
photo: list | None = None,
|
||||
video: MagicMock | None = None,
|
||||
document: MagicMock | None = None,
|
||||
voice: MagicMock | None = None,
|
||||
sticker: MagicMock | None = None,
|
||||
reply_to_message: MagicMock | None = None,
|
||||
caption: str | None = None,
|
||||
entities: list | None = None,
|
||||
caption_entities: list | None = None,
|
||||
message_thread_id: int | None = None,
|
||||
is_topic_message: bool = False,
|
||||
):
|
||||
"""创建模拟的 Telegram Update 对象。
|
||||
|
||||
Args:
|
||||
message_text: 消息文本
|
||||
chat_type: 聊天类型
|
||||
chat_id: 聊天 ID
|
||||
user_id: 用户 ID
|
||||
username: 用户名
|
||||
message_id: 消息 ID
|
||||
media_group_id: 媒体组 ID
|
||||
photo: 图片列表
|
||||
video: 视频对象
|
||||
document: 文档对象
|
||||
voice: 语音对象
|
||||
sticker: 贴纸对象
|
||||
reply_to_message: 回复的消息
|
||||
caption: 说明文字
|
||||
entities: 实体列表
|
||||
caption_entities: 说明实体列表
|
||||
message_thread_id: 消息线程 ID
|
||||
is_topic_message: 是否为主题消息
|
||||
|
||||
Returns:
|
||||
MagicMock: 模拟的 Update 对象
|
||||
"""
|
||||
update = MagicMock()
|
||||
update.update_id = 1
|
||||
|
||||
# Create message mock
|
||||
message = MagicMock()
|
||||
message.message_id = message_id
|
||||
message.chat = MagicMock()
|
||||
message.chat.id = chat_id
|
||||
message.chat.type = chat_type
|
||||
message.message_thread_id = message_thread_id
|
||||
message.is_topic_message = is_topic_message
|
||||
|
||||
# Create user mock
|
||||
from_user = MagicMock()
|
||||
from_user.id = user_id
|
||||
from_user.username = username
|
||||
message.from_user = from_user
|
||||
|
||||
# Set message content
|
||||
message.text = message_text
|
||||
message.media_group_id = media_group_id
|
||||
message.photo = photo
|
||||
message.video = video
|
||||
message.document = document
|
||||
message.voice = voice
|
||||
message.sticker = sticker
|
||||
message.reply_to_message = reply_to_message
|
||||
message.caption = caption
|
||||
message.entities = entities
|
||||
message.caption_entities = caption_entities
|
||||
|
||||
update.message = message
|
||||
update.effective_chat = message.chat
|
||||
|
||||
return update
|
||||
|
||||
|
||||
def create_mock_file(file_path: str = "https://api.telegram.org/file/test.jpg"):
|
||||
"""创建模拟的 Telegram File 对象。
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
|
||||
Returns:
|
||||
MagicMock: 模拟的 File 对象
|
||||
"""
|
||||
file = MagicMock()
|
||||
file.file_path = file_path
|
||||
file.get_file = AsyncMock(return_value=file)
|
||||
return file
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Discord 辅助函数
|
||||
# ============================================================
|
||||
|
||||
|
||||
def create_mock_discord_attachment(
|
||||
filename: str = "test.txt",
|
||||
url: str = "https://cdn.discordapp.com/test.txt",
|
||||
content_type: str | None = None,
|
||||
size: int = 1024,
|
||||
):
|
||||
"""创建模拟的 Discord Attachment 对象。
|
||||
|
||||
Args:
|
||||
filename: 文件名
|
||||
url: 文件 URL
|
||||
content_type: 内容类型
|
||||
size: 文件大小
|
||||
|
||||
Returns:
|
||||
MagicMock: 模拟的 Attachment 对象
|
||||
"""
|
||||
attachment = MagicMock()
|
||||
attachment.filename = filename
|
||||
attachment.url = url
|
||||
attachment.content_type = content_type
|
||||
attachment.size = size
|
||||
return attachment
|
||||
|
||||
|
||||
def create_mock_discord_user(
|
||||
user_id: int = 123456789,
|
||||
name: str = "TestUser",
|
||||
display_name: str = "Test User",
|
||||
bot: bool = False,
|
||||
):
|
||||
"""创建模拟的 Discord User 对象。
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
name: 用户名
|
||||
display_name: 显示名
|
||||
bot: 是否为机器人
|
||||
|
||||
Returns:
|
||||
MagicMock: 模拟的 User 对象
|
||||
"""
|
||||
user = MagicMock()
|
||||
user.id = user_id
|
||||
user.name = name
|
||||
user.display_name = display_name
|
||||
user.bot = bot
|
||||
user.mention = f"<@{user_id}>"
|
||||
return user
|
||||
|
||||
|
||||
def create_mock_discord_channel(
|
||||
channel_id: int = 111222333,
|
||||
channel_type: str = "text",
|
||||
name: str = "general",
|
||||
guild_id: int | None = 444555666,
|
||||
):
|
||||
"""创建模拟的 Discord Channel 对象。
|
||||
|
||||
Args:
|
||||
channel_id: 频道 ID
|
||||
channel_type: 频道类型
|
||||
name: 频道名
|
||||
guild_id: 服务器 ID
|
||||
|
||||
Returns:
|
||||
MagicMock: 模拟的 Channel 对象
|
||||
"""
|
||||
channel = MagicMock()
|
||||
channel.id = channel_id
|
||||
channel.name = name
|
||||
channel.type = channel_type
|
||||
|
||||
if guild_id:
|
||||
channel.guild = MagicMock()
|
||||
channel.guild.id = guild_id
|
||||
else:
|
||||
channel.guild = None
|
||||
|
||||
return channel
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 消息组件辅助函数
|
||||
# ============================================================
|
||||
|
||||
|
||||
def create_mock_message_component(
|
||||
component_type: str,
|
||||
**kwargs: Any,
|
||||
) -> BaseMessageComponent:
|
||||
"""创建模拟的消息组件。
|
||||
|
||||
Args:
|
||||
component_type: 组件类型 (plain, image, at, reply, file)
|
||||
**kwargs: 组件参数
|
||||
|
||||
Returns:
|
||||
BaseMessageComponent: 消息组件实例
|
||||
"""
|
||||
from astrbot.core.message import components as Comp
|
||||
|
||||
component_map = {
|
||||
"plain": Comp.Plain,
|
||||
"image": Comp.Image,
|
||||
"at": Comp.At,
|
||||
"reply": Comp.Reply,
|
||||
"file": Comp.File,
|
||||
}
|
||||
|
||||
component_class = component_map.get(component_type.lower())
|
||||
if not component_class:
|
||||
raise ValueError(f"Unknown component type: {component_type}")
|
||||
|
||||
return component_class(**kwargs)
|
||||
|
||||
|
||||
def create_mock_llm_response(
|
||||
completion_text: str = "Hello! How can I help you?",
|
||||
role: str = "assistant",
|
||||
tools_call_name: list[str] | None = None,
|
||||
tools_call_args: list[dict] | None = None,
|
||||
tools_call_ids: list[str] | None = None,
|
||||
):
|
||||
"""创建模拟的 LLM 响应。
|
||||
|
||||
Args:
|
||||
completion_text: 完成文本
|
||||
role: 角色
|
||||
tools_call_name: 工具调用名称列表
|
||||
tools_call_args: 工具调用参数列表
|
||||
tools_call_ids: 工具调用 ID 列表
|
||||
|
||||
Returns:
|
||||
LLMResponse: 模拟的 LLM 响应
|
||||
"""
|
||||
from astrbot.core.provider.entities import LLMResponse, TokenUsage
|
||||
|
||||
return LLMResponse(
|
||||
role=role,
|
||||
completion_text=completion_text,
|
||||
tools_call_name=tools_call_name or [],
|
||||
tools_call_args=tools_call_args or [],
|
||||
tools_call_ids=tools_call_ids or [],
|
||||
usage=TokenUsage(input_other=10, output=5),
|
||||
)
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"plain_message": {
|
||||
"type": "plain",
|
||||
"text": "Hello, this is a test message."
|
||||
},
|
||||
"image_message": {
|
||||
"type": "image",
|
||||
"url": "https://example.com/test.jpg",
|
||||
"file": null
|
||||
},
|
||||
"at_message": {
|
||||
"type": "at",
|
||||
"user_id": "12345",
|
||||
"nickname": "TestUser"
|
||||
},
|
||||
"reply_message": {
|
||||
"type": "reply",
|
||||
"id": "msg_123",
|
||||
"sender_nickname": "OriginalSender",
|
||||
"message_str": "This is the original message"
|
||||
},
|
||||
"file_message": {
|
||||
"type": "file",
|
||||
"name": "test.pdf",
|
||||
"url": "https://example.com/test.pdf"
|
||||
},
|
||||
"combined_message": {
|
||||
"components": [
|
||||
{"type": "at", "user_id": "bot_id"},
|
||||
{"type": "plain", "text": " Hello bot!"}
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
"""测试 Mock 模块。
|
||||
|
||||
提供统一的 mock 工具和 fixture,减少测试代码重复。
|
||||
|
||||
使用方式:
|
||||
# 在测试文件顶部导入需要的 fixture
|
||||
from tests.fixtures.mocks import mock_telegram_modules
|
||||
|
||||
# 或使用 Builder 类创建 mock 对象
|
||||
from tests.fixtures.mocks import MockTelegramBuilder
|
||||
bot = MockTelegramBuilder.create_bot()
|
||||
"""
|
||||
|
||||
from .aiocqhttp import (
|
||||
MockAiocqhttpBuilder,
|
||||
create_mock_aiocqhttp_modules,
|
||||
mock_aiocqhttp_modules,
|
||||
)
|
||||
from .discord import (
|
||||
MockDiscordBuilder,
|
||||
create_mock_discord_modules,
|
||||
mock_discord_modules,
|
||||
)
|
||||
from .telegram import (
|
||||
MockTelegramBuilder,
|
||||
create_mock_telegram_modules,
|
||||
mock_telegram_modules,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Telegram
|
||||
"mock_telegram_modules",
|
||||
"create_mock_telegram_modules",
|
||||
"MockTelegramBuilder",
|
||||
# Discord
|
||||
"mock_discord_modules",
|
||||
"create_mock_discord_modules",
|
||||
"MockDiscordBuilder",
|
||||
# Aiocqhttp
|
||||
"mock_aiocqhttp_modules",
|
||||
"create_mock_aiocqhttp_modules",
|
||||
"MockAiocqhttpBuilder",
|
||||
]
|
||||
Vendored
+58
@@ -0,0 +1,58 @@
|
||||
"""Aiocqhttp 模块 Mock 工具。
|
||||
|
||||
提供统一的 aiocqhttp 相关模块 mock 设置,避免在测试文件中重复定义。
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def create_mock_aiocqhttp_modules():
|
||||
"""创建 aiocqhttp 相关的 mock 模块。
|
||||
|
||||
Returns:
|
||||
dict: 包含 aiocqhttp 和相关模块的 mock 对象
|
||||
"""
|
||||
mock_aiocqhttp = MagicMock()
|
||||
mock_aiocqhttp.CQHttp = MagicMock
|
||||
mock_aiocqhttp.Event = MagicMock
|
||||
mock_aiocqhttp.exceptions = MagicMock()
|
||||
mock_aiocqhttp.exceptions.ActionFailed = Exception
|
||||
|
||||
return mock_aiocqhttp
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def mock_aiocqhttp_modules():
|
||||
"""Mock aiocqhttp 相关模块的 fixture。
|
||||
|
||||
自动应用于使用此 fixture 的测试模块。
|
||||
"""
|
||||
mock_aiocqhttp = create_mock_aiocqhttp_modules()
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
|
||||
monkeypatch.setitem(sys.modules, "aiocqhttp", mock_aiocqhttp)
|
||||
monkeypatch.setitem(sys.modules, "aiocqhttp.exceptions", mock_aiocqhttp.exceptions)
|
||||
yield
|
||||
monkeypatch.undo()
|
||||
|
||||
|
||||
class MockAiocqhttpBuilder:
|
||||
"""构建 aiocqhttp 测试 mock 对象的工具类。"""
|
||||
|
||||
@staticmethod
|
||||
def create_bot():
|
||||
"""创建 mock CQHttp bot 实例。"""
|
||||
from tests.fixtures.helpers import NoopAwaitable
|
||||
|
||||
bot = MagicMock()
|
||||
bot.send = AsyncMock()
|
||||
bot.call_action = AsyncMock()
|
||||
bot.on_request = MagicMock()
|
||||
bot.on_notice = MagicMock()
|
||||
bot.on_message = MagicMock()
|
||||
bot.on_websocket_connection = MagicMock()
|
||||
bot.run_task = MagicMock(return_value=NoopAwaitable())
|
||||
return bot
|
||||
Vendored
+140
@@ -0,0 +1,140 @@
|
||||
"""Discord 模块 Mock 工具。
|
||||
|
||||
提供统一的 Discord 相关模块 mock 设置,避免在测试文件中重复定义。
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def create_mock_discord_modules():
|
||||
"""创建 Discord 相关的 mock 模块。
|
||||
|
||||
Returns:
|
||||
dict: 包含 discord 和相关模块的 mock 对象
|
||||
"""
|
||||
mock_discord = MagicMock()
|
||||
|
||||
# Mock discord.Intents
|
||||
mock_intents = MagicMock()
|
||||
mock_intents.default = MagicMock(return_value=mock_intents)
|
||||
mock_discord.Intents = mock_intents
|
||||
|
||||
# Mock discord.Status
|
||||
mock_discord.Status = MagicMock()
|
||||
mock_discord.Status.online = "online"
|
||||
|
||||
# Mock discord.Bot
|
||||
mock_bot = MagicMock()
|
||||
mock_discord.Bot = MagicMock(return_value=mock_bot)
|
||||
|
||||
# Mock discord.Embed
|
||||
mock_embed = MagicMock()
|
||||
mock_discord.Embed = MagicMock(return_value=mock_embed)
|
||||
|
||||
# Mock discord.ui
|
||||
mock_ui = MagicMock()
|
||||
mock_ui.View = MagicMock
|
||||
mock_ui.Button = MagicMock
|
||||
mock_discord.ui = mock_ui
|
||||
|
||||
# Mock discord.Message
|
||||
mock_discord.Message = MagicMock
|
||||
|
||||
# Mock discord.Interaction
|
||||
mock_discord.Interaction = MagicMock
|
||||
mock_discord.InteractionType = MagicMock()
|
||||
mock_discord.InteractionType.application_command = 2
|
||||
mock_discord.InteractionType.component = 3
|
||||
|
||||
# Mock discord.File
|
||||
mock_discord.File = MagicMock
|
||||
|
||||
# Mock discord.SlashCommand
|
||||
mock_discord.SlashCommand = MagicMock
|
||||
|
||||
# Mock discord.Option
|
||||
mock_discord.Option = MagicMock
|
||||
|
||||
# Mock discord.SlashCommandOptionType
|
||||
mock_discord.SlashCommandOptionType = MagicMock()
|
||||
mock_discord.SlashCommandOptionType.string = 3
|
||||
|
||||
# Mock discord.errors
|
||||
mock_discord.errors = MagicMock()
|
||||
mock_discord.errors.LoginFailure = Exception
|
||||
mock_discord.errors.ConnectionClosed = Exception
|
||||
mock_discord.errors.NotFound = Exception
|
||||
mock_discord.errors.Forbidden = Exception
|
||||
|
||||
# Mock discord.abc
|
||||
mock_discord.abc = MagicMock()
|
||||
mock_discord.abc.GuildChannel = MagicMock
|
||||
mock_discord.abc.Messageable = MagicMock
|
||||
mock_discord.abc.PrivateChannel = MagicMock
|
||||
|
||||
# Mock discord.channel
|
||||
mock_channel = MagicMock()
|
||||
mock_channel.DMChannel = MagicMock
|
||||
mock_discord.channel = mock_channel
|
||||
|
||||
# Mock discord.types
|
||||
mock_discord.types = MagicMock()
|
||||
mock_discord.types.interactions = MagicMock()
|
||||
|
||||
# Mock discord.ApplicationContext
|
||||
mock_discord.ApplicationContext = MagicMock
|
||||
|
||||
# Mock discord.CustomActivity
|
||||
mock_discord.CustomActivity = MagicMock
|
||||
|
||||
return mock_discord
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def mock_discord_modules():
|
||||
"""Mock Discord 相关模块的 fixture。
|
||||
|
||||
自动应用于使用此 fixture 的测试模块。
|
||||
"""
|
||||
mock_discord = create_mock_discord_modules()
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
|
||||
monkeypatch.setitem(sys.modules, "discord", mock_discord)
|
||||
monkeypatch.setitem(sys.modules, "discord.abc", mock_discord.abc)
|
||||
monkeypatch.setitem(sys.modules, "discord.channel", mock_discord.channel)
|
||||
monkeypatch.setitem(sys.modules, "discord.errors", mock_discord.errors)
|
||||
monkeypatch.setitem(sys.modules, "discord.types", mock_discord.types)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"discord.types.interactions",
|
||||
mock_discord.types.interactions,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "discord.ui", mock_discord.ui)
|
||||
yield
|
||||
monkeypatch.undo()
|
||||
|
||||
|
||||
class MockDiscordBuilder:
|
||||
"""构建 Discord 测试 mock 对象的工具类。"""
|
||||
|
||||
@staticmethod
|
||||
def create_client():
|
||||
"""创建 mock Discord client 实例。"""
|
||||
client = MagicMock()
|
||||
client.user = MagicMock()
|
||||
client.user.id = 123456789
|
||||
client.user.display_name = "TestBot"
|
||||
client.user.name = "TestBot"
|
||||
client.get_channel = MagicMock()
|
||||
client.fetch_channel = AsyncMock()
|
||||
client.get_message = MagicMock()
|
||||
client.start = AsyncMock()
|
||||
client.close = AsyncMock()
|
||||
client.is_closed = MagicMock(return_value=False)
|
||||
client.add_application_command = MagicMock()
|
||||
client.sync_commands = AsyncMock()
|
||||
client.change_presence = AsyncMock()
|
||||
return client
|
||||
Vendored
+141
@@ -0,0 +1,141 @@
|
||||
"""Telegram 模块 Mock 工具。
|
||||
|
||||
提供统一的 Telegram 相关模块 mock 设置,避免在测试文件中重复定义。
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def create_mock_telegram_modules():
|
||||
"""创建 Telegram 相关的 mock 模块。
|
||||
|
||||
Returns:
|
||||
dict: 包含 telegram 和相关模块的 mock 对象
|
||||
"""
|
||||
mock_telegram = MagicMock()
|
||||
mock_telegram.BotCommand = MagicMock
|
||||
mock_telegram.Update = MagicMock
|
||||
mock_telegram.constants = MagicMock()
|
||||
mock_telegram.constants.ChatType = MagicMock()
|
||||
mock_telegram.constants.ChatType.PRIVATE = "private"
|
||||
mock_telegram.constants.ChatAction = MagicMock()
|
||||
mock_telegram.constants.ChatAction.TYPING = "typing"
|
||||
mock_telegram.constants.ChatAction.UPLOAD_VOICE = "upload_voice"
|
||||
mock_telegram.constants.ChatAction.UPLOAD_DOCUMENT = "upload_document"
|
||||
mock_telegram.constants.ChatAction.UPLOAD_PHOTO = "upload_photo"
|
||||
mock_telegram.error = MagicMock()
|
||||
mock_telegram.error.BadRequest = Exception
|
||||
mock_telegram.ReactionTypeCustomEmoji = MagicMock
|
||||
mock_telegram.ReactionTypeEmoji = MagicMock
|
||||
|
||||
mock_telegram_ext = MagicMock()
|
||||
mock_telegram_ext.ApplicationBuilder = MagicMock
|
||||
mock_telegram_ext.ContextTypes = MagicMock
|
||||
mock_telegram_ext.ExtBot = MagicMock
|
||||
mock_telegram_ext.filters = MagicMock()
|
||||
mock_telegram_ext.filters.ALL = MagicMock()
|
||||
mock_telegram_ext.MessageHandler = MagicMock
|
||||
|
||||
# Mock telegramify_markdown
|
||||
mock_telegramify = MagicMock()
|
||||
mock_telegramify.markdownify = lambda text, **kwargs: text
|
||||
|
||||
# Mock apscheduler
|
||||
mock_apscheduler = MagicMock()
|
||||
mock_apscheduler.schedulers = MagicMock()
|
||||
mock_apscheduler.schedulers.asyncio = MagicMock()
|
||||
mock_apscheduler.schedulers.asyncio.AsyncIOScheduler = MagicMock
|
||||
mock_apscheduler.schedulers.background = MagicMock()
|
||||
mock_apscheduler.schedulers.background.BackgroundScheduler = MagicMock
|
||||
|
||||
return {
|
||||
"telegram": mock_telegram,
|
||||
"telegram.ext": mock_telegram_ext,
|
||||
"telegramify_markdown": mock_telegramify,
|
||||
"apscheduler": mock_apscheduler,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def mock_telegram_modules():
|
||||
"""Mock Telegram 相关模块的 fixture。
|
||||
|
||||
自动应用于使用此 fixture 的测试模块。
|
||||
"""
|
||||
mocks = create_mock_telegram_modules()
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
|
||||
monkeypatch.setitem(sys.modules, "telegram", mocks["telegram"])
|
||||
monkeypatch.setitem(sys.modules, "telegram.constants", mocks["telegram"].constants)
|
||||
monkeypatch.setitem(sys.modules, "telegram.error", mocks["telegram"].error)
|
||||
monkeypatch.setitem(sys.modules, "telegram.ext", mocks["telegram.ext"])
|
||||
monkeypatch.setitem(sys.modules, "telegramify_markdown", mocks["telegramify_markdown"])
|
||||
monkeypatch.setitem(sys.modules, "apscheduler", mocks["apscheduler"])
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "apscheduler.schedulers", mocks["apscheduler"].schedulers
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"apscheduler.schedulers.asyncio",
|
||||
mocks["apscheduler"].schedulers.asyncio,
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"apscheduler.schedulers.background",
|
||||
mocks["apscheduler"].schedulers.background,
|
||||
)
|
||||
yield
|
||||
monkeypatch.undo()
|
||||
|
||||
|
||||
class MockTelegramBuilder:
|
||||
"""构建 Telegram 测试 mock 对象的工具类。"""
|
||||
|
||||
@staticmethod
|
||||
def create_bot():
|
||||
"""创建 mock Telegram bot 实例。"""
|
||||
bot = MagicMock()
|
||||
bot.username = "test_bot"
|
||||
bot.id = 12345678
|
||||
bot.base_url = "https://api.telegram.org/bottest_token_123/"
|
||||
bot.send_message = AsyncMock()
|
||||
bot.send_photo = AsyncMock()
|
||||
bot.send_document = AsyncMock()
|
||||
bot.send_voice = AsyncMock()
|
||||
bot.send_chat_action = AsyncMock()
|
||||
bot.delete_my_commands = AsyncMock()
|
||||
bot.set_my_commands = AsyncMock()
|
||||
bot.set_message_reaction = AsyncMock()
|
||||
bot.edit_message_text = AsyncMock()
|
||||
return bot
|
||||
|
||||
@staticmethod
|
||||
def create_application():
|
||||
"""创建 mock Telegram Application 实例。"""
|
||||
from tests.fixtures.helpers import NoopAwaitable
|
||||
|
||||
app = MagicMock()
|
||||
app.bot = MagicMock()
|
||||
app.bot.username = "test_bot"
|
||||
app.bot.base_url = "https://api.telegram.org/bottest_token_123/"
|
||||
app.initialize = AsyncMock()
|
||||
app.start = AsyncMock()
|
||||
app.stop = AsyncMock()
|
||||
app.add_handler = MagicMock()
|
||||
app.updater = MagicMock()
|
||||
app.updater.start_polling = MagicMock(return_value=NoopAwaitable())
|
||||
app.updater.stop = AsyncMock()
|
||||
return app
|
||||
|
||||
@staticmethod
|
||||
def create_scheduler():
|
||||
"""创建 mock APScheduler 实例。"""
|
||||
scheduler = MagicMock()
|
||||
scheduler.add_job = MagicMock()
|
||||
scheduler.start = MagicMock()
|
||||
scheduler.running = True
|
||||
scheduler.shutdown = MagicMock()
|
||||
return scheduler
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
测试插件 - 用于插件系统测试
|
||||
|
||||
这是一个最小化的测试插件,用于验证插件系统的功能。
|
||||
"""
|
||||
|
||||
from astrbot.api import llm_tool, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
|
||||
|
||||
|
||||
@star.register("test_plugin", "AstrBot Team", "测试插件 - 用于插件系统测试", "1.0.0")
|
||||
class TestPlugin(star.Star):
|
||||
"""测试插件类"""
|
||||
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
super().__init__(context)
|
||||
self.initialized = True
|
||||
|
||||
async def terminate(self) -> None:
|
||||
"""插件终止"""
|
||||
self.initialized = False
|
||||
|
||||
@filter.command("test_cmd")
|
||||
async def test_command(self, event: AstrMessageEvent) -> None:
|
||||
"""测试命令处理器。"""
|
||||
event.set_result(MessageEventResult().message("测试命令执行成功"))
|
||||
|
||||
@llm_tool("test_tool")
|
||||
async def test_llm_tool(self, query: str) -> str:
|
||||
"""测试 LLM 工具。
|
||||
|
||||
Args:
|
||||
query(string): 查询内容。
|
||||
"""
|
||||
return f"测试工具执行成功: {query}"
|
||||
|
||||
@filter.regex(r"^test_regex_(.+)$")
|
||||
async def test_regex_handler(self, event: AstrMessageEvent) -> None:
|
||||
"""测试正则处理器。"""
|
||||
event.set_result(MessageEventResult().message("正则匹配成功"))
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
name: test_plugin
|
||||
description: 测试插件 - 用于插件系统测试
|
||||
version: 1.0.0
|
||||
author: AstrBot Team
|
||||
repo: https://github.com/test/test_plugin
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Smoke tests for critical startup and import paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.core.pipeline.bootstrap import ensure_builtin_stages_registered
|
||||
from astrbot.core.pipeline.process_stage.method.agent_sub_stages.internal import (
|
||||
InternalAgentSubStage,
|
||||
)
|
||||
from astrbot.core.pipeline.process_stage.method.agent_sub_stages.third_party import (
|
||||
ThirdPartyAgentSubStage,
|
||||
)
|
||||
from astrbot.core.pipeline.stage import Stage, registered_stages
|
||||
from astrbot.core.pipeline.stage_order import STAGES_ORDER
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _run_code_in_fresh_interpreter(code: str, failure_message: str) -> None:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, (
|
||||
f"{failure_message}\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}\n"
|
||||
)
|
||||
|
||||
|
||||
def test_smoke_critical_imports_in_fresh_interpreter() -> None:
|
||||
code = (
|
||||
"import importlib;"
|
||||
"mods=["
|
||||
"'astrbot.core.core_lifecycle',"
|
||||
"'astrbot.core.astr_main_agent',"
|
||||
"'astrbot.core.pipeline.scheduler',"
|
||||
"'astrbot.core.pipeline.process_stage.method.agent_sub_stages.internal',"
|
||||
"'astrbot.core.pipeline.process_stage.method.agent_sub_stages.third_party'"
|
||||
"];"
|
||||
"[importlib.import_module(m) for m in mods]"
|
||||
)
|
||||
_run_code_in_fresh_interpreter(code, "Smoke import check failed.")
|
||||
|
||||
|
||||
def test_smoke_pipeline_stage_registration_matches_order() -> None:
|
||||
ensure_builtin_stages_registered()
|
||||
stage_names = {cls.__name__ for cls in registered_stages}
|
||||
|
||||
assert set(STAGES_ORDER).issubset(stage_names)
|
||||
assert len(stage_names) == len(registered_stages)
|
||||
|
||||
|
||||
def test_smoke_agent_sub_stages_are_stage_subclasses() -> None:
|
||||
assert issubclass(InternalAgentSubStage, Stage)
|
||||
assert issubclass(ThirdPartyAgentSubStage, Stage)
|
||||
|
||||
|
||||
def test_pipeline_package_exports_remain_compatible() -> None:
|
||||
import astrbot.core.pipeline as pipeline
|
||||
|
||||
assert pipeline.ProcessStage is not None
|
||||
assert pipeline.RespondStage is not None
|
||||
assert isinstance(pipeline.STAGES_ORDER, list)
|
||||
assert "ProcessStage" in pipeline.STAGES_ORDER
|
||||
|
||||
|
||||
def test_builtin_stage_bootstrap_is_idempotent() -> None:
|
||||
ensure_builtin_stages_registered()
|
||||
before_count = len(registered_stages)
|
||||
stage_names = {cls.__name__ for cls in registered_stages}
|
||||
|
||||
expected_stage_names = {
|
||||
"WakingCheckStage",
|
||||
"WhitelistCheckStage",
|
||||
"SessionStatusCheckStage",
|
||||
"RateLimitStage",
|
||||
"ContentSafetyCheckStage",
|
||||
"PreProcessStage",
|
||||
"ProcessStage",
|
||||
"ResultDecorateStage",
|
||||
"RespondStage",
|
||||
}
|
||||
|
||||
assert expected_stage_names.issubset(stage_names)
|
||||
|
||||
ensure_builtin_stages_registered()
|
||||
assert len(registered_stages) == before_count
|
||||
|
||||
|
||||
def test_pipeline_import_is_stable_with_mocked_apscheduler() -> None:
|
||||
"""Regression: importing pipeline should not require cron/apscheduler modules."""
|
||||
code = (
|
||||
"import sys;"
|
||||
"from unittest.mock import MagicMock;"
|
||||
"mock_apscheduler = MagicMock();"
|
||||
"mock_apscheduler.schedulers = MagicMock();"
|
||||
"mock_apscheduler.schedulers.asyncio = MagicMock();"
|
||||
"mock_apscheduler.schedulers.background = MagicMock();"
|
||||
"sys.modules['apscheduler'] = mock_apscheduler;"
|
||||
"sys.modules['apscheduler.schedulers'] = mock_apscheduler.schedulers;"
|
||||
"sys.modules['apscheduler.schedulers.asyncio'] = mock_apscheduler.schedulers.asyncio;"
|
||||
"sys.modules['apscheduler.schedulers.background'] = mock_apscheduler.schedulers.background;"
|
||||
"import astrbot.core.pipeline as pipeline;"
|
||||
"assert pipeline.ProcessStage is not None;"
|
||||
"assert pipeline.RespondStage is not None"
|
||||
)
|
||||
_run_code_in_fresh_interpreter(
|
||||
code,
|
||||
"Pipeline import should not depend on real apscheduler package.",
|
||||
)
|
||||
@@ -0,0 +1,781 @@
|
||||
"""Tests for AstrMessageEvent class."""
|
||||
|
||||
import re
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.message.components import (
|
||||
At,
|
||||
AtAll,
|
||||
Face,
|
||||
Forward,
|
||||
Image,
|
||||
Plain,
|
||||
Reply,
|
||||
)
|
||||
from astrbot.core.message.message_event_result import MessageEventResult
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageMember
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core.platform.platform_metadata import PlatformMetadata
|
||||
|
||||
|
||||
class ConcreteAstrMessageEvent(AstrMessageEvent):
|
||||
"""Concrete implementation of AstrMessageEvent for testing purposes."""
|
||||
|
||||
async def send(self, message):
|
||||
"""Send message implementation."""
|
||||
await super().send(message)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platform_meta():
|
||||
"""Create platform metadata for testing."""
|
||||
return PlatformMetadata(
|
||||
name="test_platform",
|
||||
description="Test platform",
|
||||
id="test_platform_id",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def message_member():
|
||||
"""Create a message member for testing."""
|
||||
return MessageMember(user_id="user123", nickname="TestUser")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def astrbot_message(message_member):
|
||||
"""Create an AstrBotMessage for testing."""
|
||||
message = AstrBotMessage()
|
||||
message.type = MessageType.FRIEND_MESSAGE
|
||||
message.self_id = "bot123"
|
||||
message.session_id = "session123"
|
||||
message.message_id = "msg123"
|
||||
message.sender = message_member
|
||||
message.message = [Plain(text="Hello world")]
|
||||
message.message_str = "Hello world"
|
||||
message.raw_message = None
|
||||
return message
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def astr_message_event(platform_meta, astrbot_message):
|
||||
"""Create an AstrMessageEvent instance for testing."""
|
||||
return ConcreteAstrMessageEvent(
|
||||
message_str="Hello world",
|
||||
message_obj=astrbot_message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
|
||||
|
||||
class TestAstrMessageEventInit:
|
||||
"""Tests for AstrMessageEvent initialization."""
|
||||
|
||||
def test_init_basic(self, astr_message_event):
|
||||
"""Test basic AstrMessageEvent initialization."""
|
||||
assert astr_message_event.message_str == "Hello world"
|
||||
assert astr_message_event.role == "member"
|
||||
assert astr_message_event.is_wake is False
|
||||
assert astr_message_event.is_at_or_wake_command is False
|
||||
assert astr_message_event._extras == {}
|
||||
assert astr_message_event._result is None
|
||||
assert astr_message_event.call_llm is False
|
||||
|
||||
def test_init_session(self, astr_message_event):
|
||||
"""Test session initialization."""
|
||||
assert astr_message_event.session_id == "session123"
|
||||
assert astr_message_event.session.platform_name == "test_platform_id"
|
||||
|
||||
def test_init_platform_reference(self, astr_message_event, platform_meta):
|
||||
"""Test platform reference initialization."""
|
||||
assert astr_message_event.platform_meta == platform_meta
|
||||
assert astr_message_event.platform == platform_meta # back compatibility
|
||||
|
||||
def test_init_created_at(self, astr_message_event):
|
||||
"""Test created_at timestamp is set."""
|
||||
assert astr_message_event.created_at is not None
|
||||
assert isinstance(astr_message_event.created_at, float)
|
||||
|
||||
def test_init_trace(self, astr_message_event):
|
||||
"""Test trace/span initialization."""
|
||||
assert astr_message_event.trace is not None
|
||||
assert astr_message_event.span is not None
|
||||
assert astr_message_event.trace == astr_message_event.span
|
||||
|
||||
|
||||
class TestUnifiedMsgOrigin:
|
||||
"""Tests for unified_msg_origin property."""
|
||||
|
||||
def test_unified_msg_origin_getter(self, astr_message_event):
|
||||
"""Test unified_msg_origin getter."""
|
||||
expected = "test_platform_id:FriendMessage:session123"
|
||||
assert astr_message_event.unified_msg_origin == expected
|
||||
|
||||
def test_unified_msg_origin_setter(self, astr_message_event):
|
||||
"""Test unified_msg_origin setter."""
|
||||
astr_message_event.unified_msg_origin = "new_platform:GroupMessage:new_session"
|
||||
|
||||
assert astr_message_event.session.platform_name == "new_platform"
|
||||
assert astr_message_event.session.session_id == "new_session"
|
||||
|
||||
|
||||
class TestSessionId:
|
||||
"""Tests for session_id property."""
|
||||
|
||||
def test_session_id_getter(self, astr_message_event):
|
||||
"""Test session_id getter."""
|
||||
assert astr_message_event.session_id == "session123"
|
||||
|
||||
def test_session_id_setter(self, astr_message_event):
|
||||
"""Test session_id setter."""
|
||||
astr_message_event.session_id = "new_session_id"
|
||||
|
||||
assert astr_message_event.session_id == "new_session_id"
|
||||
|
||||
|
||||
class TestGetPlatformInfo:
|
||||
"""Tests for platform info methods."""
|
||||
|
||||
def test_get_platform_name(self, astr_message_event):
|
||||
"""Test get_platform_name method."""
|
||||
assert astr_message_event.get_platform_name() == "test_platform"
|
||||
|
||||
def test_get_platform_id(self, astr_message_event):
|
||||
"""Test get_platform_id method."""
|
||||
assert astr_message_event.get_platform_id() == "test_platform_id"
|
||||
|
||||
|
||||
class TestGetMessageInfo:
|
||||
"""Tests for message info methods."""
|
||||
|
||||
def test_get_message_str(self, astr_message_event):
|
||||
"""Test get_message_str method."""
|
||||
assert astr_message_event.get_message_str() == "Hello world"
|
||||
|
||||
def test_get_message_str_none(self, platform_meta, astrbot_message):
|
||||
"""Test get_message_str keeps None when source message_str is None."""
|
||||
astrbot_message.message_str = None
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str=None,
|
||||
message_obj=astrbot_message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
assert event.get_message_str() is None
|
||||
|
||||
def test_get_messages(self, astr_message_event):
|
||||
"""Test get_messages method."""
|
||||
messages = astr_message_event.get_messages()
|
||||
assert len(messages) == 1
|
||||
assert isinstance(messages[0], Plain)
|
||||
assert messages[0].text == "Hello world"
|
||||
|
||||
def test_get_message_type(self, astr_message_event):
|
||||
"""Test get_message_type method."""
|
||||
assert astr_message_event.get_message_type() == MessageType.FRIEND_MESSAGE
|
||||
|
||||
def test_get_session_id(self, astr_message_event):
|
||||
"""Test get_session_id method."""
|
||||
assert astr_message_event.get_session_id() == "session123"
|
||||
|
||||
def test_get_group_id_empty_for_private(self, astr_message_event):
|
||||
"""Test get_group_id returns empty for private messages."""
|
||||
assert astr_message_event.get_group_id() == ""
|
||||
|
||||
def test_get_self_id(self, astr_message_event):
|
||||
"""Test get_self_id method."""
|
||||
assert astr_message_event.get_self_id() == "bot123"
|
||||
|
||||
def test_get_sender_id(self, astr_message_event):
|
||||
"""Test get_sender_id method."""
|
||||
assert astr_message_event.get_sender_id() == "user123"
|
||||
|
||||
def test_get_sender_name(self, astr_message_event):
|
||||
"""Test get_sender_name method."""
|
||||
assert astr_message_event.get_sender_name() == "TestUser"
|
||||
|
||||
def test_get_sender_name_empty_when_none(self, platform_meta, astrbot_message):
|
||||
"""Test get_sender_name returns empty string when nickname is None."""
|
||||
astrbot_message.sender = MessageMember(user_id="user123", nickname=None)
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str="test",
|
||||
message_obj=astrbot_message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
assert event.get_sender_name() == ""
|
||||
|
||||
def test_get_sender_name_coerces_non_string(self, platform_meta, astrbot_message):
|
||||
"""Test get_sender_name stringifies non-string nickname values."""
|
||||
astrbot_message.sender = MessageMember(user_id="user123", nickname=None)
|
||||
astrbot_message.sender.nickname = 12345
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str="test",
|
||||
message_obj=astrbot_message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
assert event.get_sender_name() == "12345"
|
||||
|
||||
|
||||
class TestGetMessageOutline:
|
||||
"""Tests for get_message_outline method."""
|
||||
|
||||
def test_outline_plain_text(self, astr_message_event):
|
||||
"""Test outline with plain text message."""
|
||||
outline = astr_message_event.get_message_outline()
|
||||
assert "Hello world" in outline
|
||||
|
||||
def test_outline_with_image(self, platform_meta, astrbot_message):
|
||||
"""Test outline with image component."""
|
||||
astrbot_message.message = [
|
||||
Plain(text="Look at this"),
|
||||
Image(file="http://example.com/img.jpg"),
|
||||
]
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str="Look at this",
|
||||
message_obj=astrbot_message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
outline = event.get_message_outline()
|
||||
assert "Look at this" in outline
|
||||
assert "[图片]" in outline
|
||||
|
||||
def test_outline_with_at(self, platform_meta, astrbot_message):
|
||||
"""Test outline with At component."""
|
||||
astrbot_message.message = [At(qq="12345"), Plain(text=" hello")]
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str=" hello",
|
||||
message_obj=astrbot_message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
outline = event.get_message_outline()
|
||||
assert "[At:12345]" in outline
|
||||
|
||||
def test_outline_with_at_all(self, platform_meta, astrbot_message):
|
||||
"""Test outline with AtAll component."""
|
||||
astrbot_message.message = [AtAll()]
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str="",
|
||||
message_obj=astrbot_message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
outline = event.get_message_outline()
|
||||
# AtAll format is "[At:all]" in the actual implementation
|
||||
assert "[At:" in outline and "all" in outline.lower()
|
||||
|
||||
def test_outline_with_face(self, platform_meta, astrbot_message):
|
||||
"""Test outline with Face component."""
|
||||
astrbot_message.message = [Face(id="123")]
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str="",
|
||||
message_obj=astrbot_message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
outline = event.get_message_outline()
|
||||
assert "[表情:123]" in outline
|
||||
|
||||
def test_outline_with_forward(self, platform_meta, astrbot_message):
|
||||
"""Test outline with Forward component."""
|
||||
# Forward requires an id parameter
|
||||
astrbot_message.message = [Forward(id="test_forward_id")]
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str="",
|
||||
message_obj=astrbot_message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
outline = event.get_message_outline()
|
||||
assert "[转发消息]" in outline
|
||||
|
||||
def test_outline_with_reply(self, platform_meta, astrbot_message):
|
||||
"""Test outline with Reply component."""
|
||||
# Reply requires an id parameter
|
||||
reply = Reply(id="test_reply_id")
|
||||
reply.message_str = "Original message"
|
||||
reply.sender_nickname = "Sender"
|
||||
astrbot_message.message = [reply, Plain(text=" reply")]
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str=" reply",
|
||||
message_obj=astrbot_message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
outline = event.get_message_outline()
|
||||
assert "[引用消息(Sender: Original message)]" in outline
|
||||
|
||||
def test_outline_with_reply_no_message(self, platform_meta, astrbot_message):
|
||||
"""Test outline with Reply component without message_str."""
|
||||
# Reply requires an id parameter
|
||||
reply = Reply(id="test_reply_id")
|
||||
reply.message_str = None
|
||||
astrbot_message.message = [reply]
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str="",
|
||||
message_obj=astrbot_message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
outline = event.get_message_outline()
|
||||
assert "[引用消息]" in outline
|
||||
|
||||
def test_outline_empty_chain(self, platform_meta, astrbot_message):
|
||||
"""Test outline with empty message chain."""
|
||||
astrbot_message.message = []
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str="",
|
||||
message_obj=astrbot_message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
outline = event.get_message_outline()
|
||||
assert outline == ""
|
||||
|
||||
def test_outline_very_long_plain_text(self, platform_meta, astrbot_message):
|
||||
"""Test outline generation for very long plain text content."""
|
||||
long_text = "A" * 20000
|
||||
astrbot_message.message = [Plain(text=long_text)]
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str=long_text,
|
||||
message_obj=astrbot_message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
outline = event.get_message_outline()
|
||||
assert outline.startswith("A")
|
||||
assert len(outline) >= 20000
|
||||
|
||||
|
||||
class TestExtras:
|
||||
"""Tests for extra information methods."""
|
||||
|
||||
def test_set_extra(self, astr_message_event):
|
||||
"""Test set_extra method."""
|
||||
astr_message_event.set_extra("key1", "value1")
|
||||
assert astr_message_event._extras["key1"] == "value1"
|
||||
|
||||
def test_get_extra_with_key(self, astr_message_event):
|
||||
"""Test get_extra with specific key."""
|
||||
astr_message_event.set_extra("key1", "value1")
|
||||
assert astr_message_event.get_extra("key1") == "value1"
|
||||
|
||||
def test_get_extra_with_default(self, astr_message_event):
|
||||
"""Test get_extra with default value."""
|
||||
result = astr_message_event.get_extra("nonexistent", "default_value")
|
||||
assert result == "default_value"
|
||||
|
||||
def test_get_extra_all(self, astr_message_event):
|
||||
"""Test get_extra without key returns all extras."""
|
||||
astr_message_event.set_extra("key1", "value1")
|
||||
astr_message_event.set_extra("key2", "value2")
|
||||
all_extras = astr_message_event.get_extra()
|
||||
assert all_extras == {"key1": "value1", "key2": "value2"}
|
||||
|
||||
def test_clear_extra(self, astr_message_event):
|
||||
"""Test clear_extra method."""
|
||||
astr_message_event.set_extra("key1", "value1")
|
||||
astr_message_event.clear_extra()
|
||||
assert astr_message_event._extras == {}
|
||||
|
||||
|
||||
class TestSetResult:
|
||||
"""Tests for set_result method."""
|
||||
|
||||
def test_set_result_with_message_event_result(self, astr_message_event):
|
||||
"""Test set_result with MessageEventResult object."""
|
||||
result = MessageEventResult().message("Test message")
|
||||
astr_message_event.set_result(result)
|
||||
|
||||
assert astr_message_event._result == result
|
||||
|
||||
def test_set_result_with_string(self, astr_message_event):
|
||||
"""Test set_result with string creates MessageEventResult."""
|
||||
astr_message_event.set_result("Test message")
|
||||
|
||||
assert astr_message_event._result is not None
|
||||
assert len(astr_message_event._result.chain) == 1
|
||||
assert isinstance(astr_message_event._result.chain[0], Plain)
|
||||
|
||||
def test_set_result_with_empty_chain(self, astr_message_event):
|
||||
"""Test set_result handles empty chain correctly."""
|
||||
result = MessageEventResult()
|
||||
# chain is already an empty list by default
|
||||
astr_message_event.set_result(result)
|
||||
|
||||
assert astr_message_event._result.chain == []
|
||||
|
||||
|
||||
class TestStopContinueEvent:
|
||||
"""Tests for stop_event and continue_event methods."""
|
||||
|
||||
def test_stop_event_creates_result_if_none(self, astr_message_event):
|
||||
"""Test stop_event creates result if none exists."""
|
||||
astr_message_event.stop_event()
|
||||
|
||||
assert astr_message_event._result is not None
|
||||
assert astr_message_event.is_stopped() is True
|
||||
|
||||
def test_stop_event_with_existing_result(self, astr_message_event):
|
||||
"""Test stop_event with existing result."""
|
||||
astr_message_event.set_result(MessageEventResult().message("Test"))
|
||||
astr_message_event.stop_event()
|
||||
|
||||
assert astr_message_event.is_stopped() is True
|
||||
|
||||
def test_continue_event_creates_result_if_none(self, astr_message_event):
|
||||
"""Test continue_event creates result if none exists."""
|
||||
astr_message_event.continue_event()
|
||||
|
||||
assert astr_message_event._result is not None
|
||||
assert astr_message_event.is_stopped() is False
|
||||
|
||||
def test_continue_event_with_existing_result(self, astr_message_event):
|
||||
"""Test continue_event with existing result."""
|
||||
astr_message_event.set_result(MessageEventResult().message("Test"))
|
||||
astr_message_event.stop_event()
|
||||
astr_message_event.continue_event()
|
||||
|
||||
assert astr_message_event.is_stopped() is False
|
||||
|
||||
def test_is_stopped_default_false(self, astr_message_event):
|
||||
"""Test is_stopped returns False by default."""
|
||||
assert astr_message_event.is_stopped() is False
|
||||
|
||||
|
||||
class TestIsPrivateChat:
|
||||
"""Tests for is_private_chat method."""
|
||||
|
||||
def test_is_private_chat_true(self, astr_message_event):
|
||||
"""Test is_private_chat returns True for friend message."""
|
||||
assert astr_message_event.is_private_chat() is True
|
||||
|
||||
def test_is_private_chat_false(self, platform_meta, astrbot_message):
|
||||
"""Test is_private_chat returns False for group message."""
|
||||
astrbot_message.type = MessageType.GROUP_MESSAGE
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str="test",
|
||||
message_obj=astrbot_message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
assert event.is_private_chat() is False
|
||||
|
||||
|
||||
class TestIsWakeUp:
|
||||
"""Tests for is_wake_up method."""
|
||||
|
||||
def test_is_wake_up_default_false(self, astr_message_event):
|
||||
"""Test is_wake_up returns False by default."""
|
||||
assert astr_message_event.is_wake_up() is False
|
||||
|
||||
def test_is_wake_up_when_set(self, astr_message_event):
|
||||
"""Test is_wake_up returns True when is_wake is set."""
|
||||
astr_message_event.is_wake = True
|
||||
assert astr_message_event.is_wake_up() is True
|
||||
|
||||
|
||||
class TestIsAdmin:
|
||||
"""Tests for is_admin method."""
|
||||
|
||||
def test_is_admin_default_false(self, astr_message_event):
|
||||
"""Test is_admin returns False by default."""
|
||||
assert astr_message_event.is_admin() is False
|
||||
|
||||
def test_is_admin_when_admin(self, astr_message_event):
|
||||
"""Test is_admin returns True when role is admin."""
|
||||
astr_message_event.role = "admin"
|
||||
assert astr_message_event.is_admin() is True
|
||||
|
||||
|
||||
class TestProcessBuffer:
|
||||
"""Tests for process_buffer method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_buffer_splits_by_pattern(self, astr_message_event):
|
||||
"""Test process_buffer splits buffer by pattern."""
|
||||
buffer = "Line 1\nLine 2\nLine 3\nRemaining"
|
||||
pattern = re.compile(r".*\n")
|
||||
|
||||
with patch.object(
|
||||
astr_message_event, "send", new_callable=AsyncMock
|
||||
) as mock_send:
|
||||
result = await astr_message_event.process_buffer(buffer, pattern)
|
||||
|
||||
# Should have sent 3 lines and remaining should be "Remaining"
|
||||
assert mock_send.call_count == 3
|
||||
assert result == "Remaining"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_buffer_no_match(self, astr_message_event):
|
||||
"""Test process_buffer returns original when no match."""
|
||||
buffer = "No newlines here"
|
||||
pattern = re.compile(r"\n")
|
||||
|
||||
result = await astr_message_event.process_buffer(buffer, pattern)
|
||||
|
||||
assert result == "No newlines here"
|
||||
|
||||
|
||||
class TestResultHelpers:
|
||||
"""Tests for result helper methods."""
|
||||
|
||||
def test_make_result(self, astr_message_event):
|
||||
"""Test make_result creates empty MessageEventResult."""
|
||||
result = astr_message_event.make_result()
|
||||
assert isinstance(result, MessageEventResult)
|
||||
|
||||
def test_plain_result(self, astr_message_event):
|
||||
"""Test plain_result creates result with text."""
|
||||
result = astr_message_event.plain_result("Hello")
|
||||
|
||||
assert isinstance(result, MessageEventResult)
|
||||
assert len(result.chain) == 1
|
||||
assert isinstance(result.chain[0], Plain)
|
||||
assert result.chain[0].text == "Hello"
|
||||
|
||||
def test_image_result_url(self, astr_message_event):
|
||||
"""Test image_result with URL."""
|
||||
result = astr_message_event.image_result("http://example.com/image.jpg")
|
||||
|
||||
assert isinstance(result, MessageEventResult)
|
||||
assert len(result.chain) == 1
|
||||
assert isinstance(result.chain[0], Image)
|
||||
|
||||
def test_image_result_path(self, astr_message_event):
|
||||
"""Test image_result with file path."""
|
||||
result = astr_message_event.image_result("/path/to/image.jpg")
|
||||
|
||||
assert isinstance(result, MessageEventResult)
|
||||
assert len(result.chain) == 1
|
||||
assert isinstance(result.chain[0], Image)
|
||||
|
||||
|
||||
class TestGetResult:
|
||||
"""Tests for get_result and clear_result methods."""
|
||||
|
||||
def test_get_result_returns_none_by_default(self, astr_message_event):
|
||||
"""Test get_result returns None by default."""
|
||||
assert astr_message_event.get_result() is None
|
||||
|
||||
def test_get_result_returns_set_result(self, astr_message_event):
|
||||
"""Test get_result returns set result."""
|
||||
result = MessageEventResult().message("Test")
|
||||
astr_message_event.set_result(result)
|
||||
|
||||
assert astr_message_event.get_result() == result
|
||||
|
||||
def test_clear_result(self, astr_message_event):
|
||||
"""Test clear_result clears the result."""
|
||||
astr_message_event.set_result(MessageEventResult().message("Test"))
|
||||
astr_message_event.clear_result()
|
||||
|
||||
assert astr_message_event.get_result() is None
|
||||
|
||||
|
||||
class TestShouldCallLlm:
|
||||
"""Tests for should_call_llm method."""
|
||||
|
||||
def test_should_call_llm_default(self, astr_message_event):
|
||||
"""Test call_llm default is False."""
|
||||
assert astr_message_event.call_llm is False
|
||||
|
||||
def test_should_call_llm_when_set(self, astr_message_event):
|
||||
"""Test should_call_llm sets call_llm."""
|
||||
astr_message_event.should_call_llm(True)
|
||||
assert astr_message_event.call_llm is True
|
||||
|
||||
|
||||
class TestRequestLlm:
|
||||
"""Tests for request_llm method."""
|
||||
|
||||
def test_request_llm_basic(self, astr_message_event):
|
||||
"""Test request_llm creates ProviderRequest."""
|
||||
request = astr_message_event.request_llm(prompt="Hello")
|
||||
|
||||
assert request.prompt == "Hello"
|
||||
assert request.session_id == ""
|
||||
assert request.image_urls == []
|
||||
assert request.contexts == []
|
||||
|
||||
def test_request_llm_with_all_params(self, astr_message_event):
|
||||
"""Test request_llm with all parameters."""
|
||||
request = astr_message_event.request_llm(
|
||||
prompt="Hello",
|
||||
session_id="session123",
|
||||
image_urls=["http://example.com/img.jpg"],
|
||||
contexts=[{"role": "user", "content": "Hi"}],
|
||||
system_prompt="You are helpful",
|
||||
)
|
||||
|
||||
assert request.prompt == "Hello"
|
||||
assert request.session_id == "session123"
|
||||
assert request.image_urls == ["http://example.com/img.jpg"]
|
||||
assert request.contexts == [{"role": "user", "content": "Hi"}]
|
||||
assert request.system_prompt == "You are helpful"
|
||||
|
||||
|
||||
class TestSendStreaming:
|
||||
"""Tests for send_streaming method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_streaming_sets_has_send_oper(self, astr_message_event):
|
||||
"""Test send_streaming sets _has_send_oper flag."""
|
||||
assert astr_message_event._has_send_oper is False
|
||||
|
||||
async def generator():
|
||||
yield MessageEventResult().message("Test")
|
||||
|
||||
with patch(
|
||||
"astrbot.core.platform.astr_message_event.Metric.upload",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
await astr_message_event.send_streaming(generator())
|
||||
|
||||
assert astr_message_event._has_send_oper is True
|
||||
|
||||
|
||||
class TestSendTyping:
|
||||
"""Tests for send_typing method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_typing_default_empty(self, astr_message_event):
|
||||
"""Test send_typing default implementation is empty."""
|
||||
# Should not raise any exception
|
||||
await astr_message_event.send_typing()
|
||||
|
||||
|
||||
class TestReact:
|
||||
"""Tests for react method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_react_sends_emoji(self, astr_message_event):
|
||||
"""Test react sends emoji as message."""
|
||||
with patch.object(
|
||||
astr_message_event, "send", new_callable=AsyncMock
|
||||
) as mock_send:
|
||||
await astr_message_event.react("👍")
|
||||
|
||||
mock_send.assert_called_once()
|
||||
call_arg = mock_send.call_args[0][0]
|
||||
# MessageChain is a dataclass with chain attribute
|
||||
assert len(call_arg.chain) == 1
|
||||
assert isinstance(call_arg.chain[0], Plain)
|
||||
assert call_arg.chain[0].text == "👍"
|
||||
|
||||
|
||||
class TestGetGroup:
|
||||
"""Tests for get_group method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_group_returns_none_for_private(self, astr_message_event):
|
||||
"""Test get_group returns None for private chat."""
|
||||
result = await astr_message_event.get_group()
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_group_with_group_id_param(self, astr_message_event):
|
||||
"""Test get_group with group_id parameter."""
|
||||
# Default implementation returns None
|
||||
result = await astr_message_event.get_group(group_id="group123")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestMessageTypeHandling:
|
||||
"""Tests for message type handling edge cases."""
|
||||
|
||||
def test_message_type_from_valid_string(self, platform_meta):
|
||||
"""Valid MessageType string should be converted correctly."""
|
||||
message = AstrBotMessage()
|
||||
message.type = "FRIEND_MESSAGE"
|
||||
message.message = []
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str="test",
|
||||
message_obj=message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
assert event.session.message_type == MessageType.FRIEND_MESSAGE
|
||||
assert event.get_message_type() == MessageType.FRIEND_MESSAGE
|
||||
|
||||
def test_message_type_from_invalid_string_defaults_to_friend(self, platform_meta):
|
||||
"""Invalid message type should default to FRIEND_MESSAGE."""
|
||||
message = AstrBotMessage()
|
||||
message.type = "InvalidMessageType"
|
||||
message.message = []
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str="test",
|
||||
message_obj=message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
assert event.session.message_type == MessageType.FRIEND_MESSAGE
|
||||
assert event.get_message_type() == MessageType.FRIEND_MESSAGE
|
||||
|
||||
def test_message_type_from_none_defaults_to_friend(self, platform_meta):
|
||||
"""None message type should default to FRIEND_MESSAGE."""
|
||||
message = AstrBotMessage()
|
||||
message.type = None
|
||||
message.message = []
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str="test",
|
||||
message_obj=message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
assert event.session.message_type == MessageType.FRIEND_MESSAGE
|
||||
assert event.get_message_type() == MessageType.FRIEND_MESSAGE
|
||||
|
||||
def test_message_type_from_integer_defaults_to_friend(self, platform_meta):
|
||||
"""Integer message type should default to FRIEND_MESSAGE."""
|
||||
message = AstrBotMessage()
|
||||
message.type = 123
|
||||
message.message = []
|
||||
event = ConcreteAstrMessageEvent(
|
||||
message_str="test",
|
||||
message_obj=message,
|
||||
platform_meta=platform_meta,
|
||||
session_id="session123",
|
||||
)
|
||||
assert event.session.message_type == MessageType.FRIEND_MESSAGE
|
||||
assert event.get_message_type() == MessageType.FRIEND_MESSAGE
|
||||
|
||||
|
||||
class TestDefensiveGetattr:
|
||||
"""Tests for defensive getattr behavior in AstrMessageEvent."""
|
||||
|
||||
def test_get_messages_without_message_attr(self, astr_message_event):
|
||||
"""get_messages should handle message_obj without 'message' attribute."""
|
||||
astr_message_event.message_obj = type("DummyMessage", (), {})()
|
||||
messages = astr_message_event.get_messages()
|
||||
assert isinstance(messages, list)
|
||||
|
||||
def test_get_message_type_without_type_attr(self, astr_message_event):
|
||||
"""get_message_type should handle message_obj without 'type' attribute."""
|
||||
astr_message_event.message_obj = type("DummyMessage", (), {})()
|
||||
message_type = astr_message_event.get_message_type()
|
||||
assert isinstance(message_type, MessageType)
|
||||
|
||||
def test_get_sender_fields_without_sender_attr(self, astr_message_event):
|
||||
"""get_sender_id and get_sender_name should handle missing 'sender'."""
|
||||
astr_message_event.message_obj = type("DummyMessage", (), {})()
|
||||
sender_id = astr_message_event.get_sender_id()
|
||||
sender_name = astr_message_event.get_sender_name()
|
||||
assert isinstance(sender_id, str)
|
||||
assert isinstance(sender_name, str)
|
||||
|
||||
def test_get_message_type_with_non_enum_type(self, astr_message_event):
|
||||
"""get_message_type should handle message_obj.type that is not a MessageType."""
|
||||
class DummyMessage:
|
||||
def __init__(self):
|
||||
self.type = "not_an_enum"
|
||||
self.message = []
|
||||
astr_message_event.message_obj = DummyMessage()
|
||||
message_type = astr_message_event.get_message_type()
|
||||
assert isinstance(message_type, MessageType)
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Tests for AstrBotMessage and MessageMember classes."""
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from astrbot.core.message.components import Image, Plain
|
||||
from astrbot.core.platform.astrbot_message import AstrBotMessage, Group, MessageMember
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
|
||||
|
||||
class TestMessageMember:
|
||||
"""Tests for MessageMember dataclass."""
|
||||
|
||||
def test_message_member_creation_basic(self):
|
||||
"""Test creating a MessageMember with required fields."""
|
||||
member = MessageMember(user_id="user123")
|
||||
|
||||
assert member.user_id == "user123"
|
||||
assert member.nickname is None
|
||||
|
||||
def test_message_member_creation_with_nickname(self):
|
||||
"""Test creating a MessageMember with nickname."""
|
||||
member = MessageMember(user_id="user123", nickname="TestUser")
|
||||
|
||||
assert member.user_id == "user123"
|
||||
assert member.nickname == "TestUser"
|
||||
|
||||
def test_message_member_str_with_nickname(self):
|
||||
"""Test __str__ method with nickname."""
|
||||
member = MessageMember(user_id="user123", nickname="TestUser")
|
||||
result = str(member)
|
||||
|
||||
assert "User ID: user123" in result
|
||||
assert "Nickname: TestUser" in result
|
||||
|
||||
def test_message_member_str_without_nickname(self):
|
||||
"""Test __str__ method without nickname."""
|
||||
member = MessageMember(user_id="user123")
|
||||
result = str(member)
|
||||
|
||||
assert "User ID: user123" in result
|
||||
assert "Nickname: N/A" in result
|
||||
|
||||
|
||||
class TestGroup:
|
||||
"""Tests for Group dataclass."""
|
||||
|
||||
def test_group_creation_basic(self):
|
||||
"""Test creating a Group with required fields."""
|
||||
group = Group(group_id="group123")
|
||||
|
||||
assert group.group_id == "group123"
|
||||
assert group.group_name is None
|
||||
assert group.group_avatar is None
|
||||
assert group.group_owner is None
|
||||
assert group.group_admins is None
|
||||
assert group.members is None
|
||||
|
||||
def test_group_creation_with_all_fields(self):
|
||||
"""Test creating a Group with all fields."""
|
||||
members = [MessageMember(user_id="user1"), MessageMember(user_id="user2")]
|
||||
group = Group(
|
||||
group_id="group123",
|
||||
group_name="Test Group",
|
||||
group_avatar="http://example.com/avatar.jpg",
|
||||
group_owner="owner123",
|
||||
group_admins=["admin1", "admin2"],
|
||||
members=members,
|
||||
)
|
||||
|
||||
assert group.group_id == "group123"
|
||||
assert group.group_name == "Test Group"
|
||||
assert group.group_avatar == "http://example.com/avatar.jpg"
|
||||
assert group.group_owner == "owner123"
|
||||
assert group.group_admins == ["admin1", "admin2"]
|
||||
assert group.members == members
|
||||
|
||||
def test_group_str_with_all_fields(self):
|
||||
"""Test __str__ method with all fields."""
|
||||
members = [MessageMember(user_id="user1", nickname="User One")]
|
||||
group = Group(
|
||||
group_id="group123",
|
||||
group_name="Test Group",
|
||||
group_avatar="http://example.com/avatar.jpg",
|
||||
group_owner="owner123",
|
||||
group_admins=["admin1"],
|
||||
members=members,
|
||||
)
|
||||
result = str(group)
|
||||
|
||||
assert "Group ID: group123" in result
|
||||
assert "Name: Test Group" in result
|
||||
assert "Avatar: http://example.com/avatar.jpg" in result
|
||||
assert "Owner ID: owner123" in result
|
||||
assert "Admin IDs: ['admin1']" in result
|
||||
assert "Members Len: 1" in result
|
||||
|
||||
def test_group_str_with_minimal_fields(self):
|
||||
"""Test __str__ method with minimal fields."""
|
||||
group = Group(group_id="group123")
|
||||
result = str(group)
|
||||
|
||||
assert "Group ID: group123" in result
|
||||
assert "Name: N/A" in result
|
||||
assert "Avatar: N/A" in result
|
||||
assert "Owner ID: N/A" in result
|
||||
assert "Admin IDs: N/A" in result
|
||||
assert "Members Len: 0" in result
|
||||
assert "First Member: N/A" in result
|
||||
|
||||
|
||||
class TestAstrBotMessage:
|
||||
"""Tests for AstrBotMessage class."""
|
||||
|
||||
def test_astrbot_message_creation(self):
|
||||
"""Test creating an AstrBotMessage."""
|
||||
message = AstrBotMessage()
|
||||
|
||||
assert message.group is None
|
||||
assert message.timestamp is not None
|
||||
assert isinstance(message.timestamp, int)
|
||||
|
||||
def test_astrbot_message_timestamp(self):
|
||||
"""Test timestamp is set on creation."""
|
||||
with patch.object(time, "time", return_value=1234567890):
|
||||
message = AstrBotMessage()
|
||||
assert message.timestamp == 1234567890
|
||||
|
||||
def test_astrbot_message_all_attributes(self):
|
||||
"""Test setting all attributes on AstrBotMessage."""
|
||||
message = AstrBotMessage()
|
||||
message.type = MessageType.FRIEND_MESSAGE
|
||||
message.self_id = "bot123"
|
||||
message.session_id = "session123"
|
||||
message.message_id = "msg123"
|
||||
message.sender = MessageMember(user_id="user123", nickname="TestUser")
|
||||
message.message = [Plain(text="Hello")]
|
||||
message.message_str = "Hello"
|
||||
message.raw_message = {"raw": "data"}
|
||||
|
||||
assert message.type == MessageType.FRIEND_MESSAGE
|
||||
assert message.self_id == "bot123"
|
||||
assert message.session_id == "session123"
|
||||
assert message.message_id == "msg123"
|
||||
assert message.sender.user_id == "user123"
|
||||
assert len(message.message) == 1
|
||||
assert message.message_str == "Hello"
|
||||
assert message.raw_message == {"raw": "data"}
|
||||
|
||||
def test_astrbot_message_str(self):
|
||||
"""Test __str__ method."""
|
||||
message = AstrBotMessage()
|
||||
message.type = MessageType.FRIEND_MESSAGE
|
||||
message.self_id = "bot123"
|
||||
|
||||
result = str(message)
|
||||
assert "'type'" in result
|
||||
assert "'self_id'" in result
|
||||
|
||||
|
||||
class TestAstrBotMessageGroupId:
|
||||
"""Tests for AstrBotMessage group_id property."""
|
||||
|
||||
def test_group_id_returns_empty_when_no_group(self):
|
||||
"""Test group_id returns empty string when group is None."""
|
||||
message = AstrBotMessage()
|
||||
assert message.group_id == ""
|
||||
|
||||
def test_group_id_returns_group_id_when_group_exists(self):
|
||||
"""Test group_id returns the group's id when group exists."""
|
||||
message = AstrBotMessage()
|
||||
message.group = Group(group_id="group123")
|
||||
|
||||
assert message.group_id == "group123"
|
||||
|
||||
def test_group_id_setter_creates_new_group(self):
|
||||
"""Test group_id setter creates a new group if none exists."""
|
||||
message = AstrBotMessage()
|
||||
message.group_id = "new_group123"
|
||||
|
||||
assert message.group is not None
|
||||
assert message.group.group_id == "new_group123"
|
||||
|
||||
def test_group_id_setter_updates_existing_group(self):
|
||||
"""Test group_id setter updates existing group's id."""
|
||||
message = AstrBotMessage()
|
||||
message.group = Group(group_id="old_group")
|
||||
message.group_id = "new_group"
|
||||
|
||||
assert message.group.group_id == "new_group"
|
||||
|
||||
def test_group_id_setter_with_none_removes_group(self):
|
||||
"""Test group_id setter with None removes the group."""
|
||||
message = AstrBotMessage()
|
||||
message.group = Group(group_id="group123")
|
||||
message.group_id = None
|
||||
|
||||
assert message.group is None
|
||||
|
||||
def test_group_id_setter_with_empty_string_removes_group(self):
|
||||
"""Test group_id setter with empty string removes the group."""
|
||||
message = AstrBotMessage()
|
||||
message.group = Group(group_id="group123")
|
||||
message.group_id = ""
|
||||
|
||||
assert message.group is None
|
||||
|
||||
|
||||
class TestAstrBotMessageTypes:
|
||||
"""Tests for AstrBotMessage with different message types."""
|
||||
|
||||
def test_friend_message_type(self):
|
||||
"""Test AstrBotMessage with FRIEND_MESSAGE type."""
|
||||
message = AstrBotMessage()
|
||||
message.type = MessageType.FRIEND_MESSAGE
|
||||
|
||||
assert message.type == MessageType.FRIEND_MESSAGE
|
||||
assert message.type.value == "FriendMessage"
|
||||
|
||||
def test_group_message_type(self):
|
||||
"""Test AstrBotMessage with GROUP_MESSAGE type."""
|
||||
message = AstrBotMessage()
|
||||
message.type = MessageType.GROUP_MESSAGE
|
||||
|
||||
assert message.type == MessageType.GROUP_MESSAGE
|
||||
assert message.type.value == "GroupMessage"
|
||||
|
||||
def test_other_message_type(self):
|
||||
"""Test AstrBotMessage with OTHER_MESSAGE type."""
|
||||
message = AstrBotMessage()
|
||||
message.type = MessageType.OTHER_MESSAGE
|
||||
|
||||
assert message.type == MessageType.OTHER_MESSAGE
|
||||
assert message.type.value == "OtherMessage"
|
||||
|
||||
|
||||
class TestAstrBotMessageChain:
|
||||
"""Tests for AstrBotMessage message chain."""
|
||||
|
||||
def test_message_chain_with_plain_text(self):
|
||||
"""Test message chain with plain text."""
|
||||
message = AstrBotMessage()
|
||||
message.message = [Plain(text="Hello world")]
|
||||
|
||||
assert len(message.message) == 1
|
||||
assert isinstance(message.message[0], Plain)
|
||||
assert message.message[0].text == "Hello world"
|
||||
|
||||
def test_message_chain_with_multiple_components(self):
|
||||
"""Test message chain with multiple components."""
|
||||
message = AstrBotMessage()
|
||||
message.message = [
|
||||
Plain(text="Hello "),
|
||||
Plain(text="world"),
|
||||
Image(file="http://example.com/img.jpg"),
|
||||
]
|
||||
|
||||
assert len(message.message) == 3
|
||||
assert isinstance(message.message[0], Plain)
|
||||
assert isinstance(message.message[1], Plain)
|
||||
assert isinstance(message.message[2], Image)
|
||||
|
||||
def test_message_chain_empty(self):
|
||||
"""Test empty message chain."""
|
||||
message = AstrBotMessage()
|
||||
message.message = []
|
||||
|
||||
assert len(message.message) == 0
|
||||
Reference in New Issue
Block a user