Compare commits

...

47 Commits

Author SHA1 Message Date
Soulter 20d6ff4620 chore: bump version to 4.17.5 2026-02-18 22:04:43 +08:00
Chiu Chun-Hsien a2b61e2ab8 refactor: extract Voice_messages_forbidden fallback into shared helper with typed BadRequest exception (#5204)
- Add _send_voice_with_fallback helper to deduplicate voice forbidden handling
- Catch telegram.error.BadRequest instead of bare Exception with string matching
- Add text field to Record component to preserve TTS source text
- Store original text in Record during TTS conversion for use as document caption
- Skip _send_chat_action when chat_id is empty to avoid unnecessary warnings
2026-02-18 21:45:19 +08:00
sanyekana c6289d8f75 feat(core): add plugin error hook for custom error routing (#5192)
* feat(core): add plugin error hook for custom error routing

* fix(core): align plugin error suppression with event stop state
2026-02-18 21:38:27 +08:00
Soulter 567390e27c feat: add LINE support to multiple language README files 2026-02-18 21:35:27 +08:00
Soulter 0c0f8bf484 chore: ruff format 2026-02-18 18:22:06 +08:00
Soulter ae0a9cb591 docs: update readme 2026-02-18 18:20:08 +08:00
Soulter 3f4d7255a0 feat: supports aihubmix 2026-02-18 18:11:13 +08:00
Soulter b8d2499475 feat: add MarketPluginCard component and integrate random plugin feature in ExtensionPage (#5190)
* feat: add MarketPluginCard component and integrate random plugin feature in ExtensionPage

* feat: update random plugin selection logic to use pluginMarketData and refresh on relevant events
2026-02-18 17:29:04 +08:00
SnowNightt 8cb26d886f fix: 修复选择配置文件进入配置文件管理弹窗直接关闭弹窗显示的配置文件不正确 (#5174) 2026-02-18 16:33:18 +08:00
時壹 3ca8dd204f fix: prevent duplicate error message when all LLM providers fail (#5183) 2026-02-18 16:29:35 +08:00
Soulter 3476afce41 feat: supports send markdown message in qqofficial (#5173)
* feat: supports send markdown message in qqofficial

closes: #1093 #918 #4180 #4264

* ruff format
2026-02-18 00:35:52 +08:00
Soulter 9b0e24ec49 chore: bump version to 4.17.4 2026-02-17 21:19:53 +08:00
Soulter 92d71fffe9 feat: add announcement section to WelcomePage and localize announcement title 2026-02-17 21:15:12 +08:00
Soulter 80c22f4f72 feat: add FAQ link to vertical sidebar and update navigation for localization 2026-02-17 21:01:29 +08:00
Soulter 6e22d266dd feat: implement search functionality in configuration components and update UI (#5168) 2026-02-17 20:47:24 +08:00
Soulter 4c285fb521 feat: add unsaved changes notice to configuration page and update messages 2026-02-17 20:32:25 +08:00
Helian Nuits 51c3521aaa ui(CronJobPage): fix action column buttons overlapping in CronJobPage (#5163)
- 修改前:操作列容器仅使用 `d-flex`,在页面宽度变窄时,子元素(开关和删除按钮)会因为宽度挤压而发生视觉重叠,甚至堆叠在一起。
- 修改后:
    1. 为容器添加了 `flex-nowrap`,强制禁止子元素换行。
    2. 设置了 `min-width: 140px`,确保该列拥有固定的保护空间,防止被其他长文本列挤压。
    3. 增加了 `gap: 12px` 间距,提升了操作辨识度并优化了点击体验。
2026-02-17 18:58:05 +08:00
Soulter 32112a3326 feat: enhance PersonaForm component with responsive design and improved styling (#5162)
fix: #5159
2026-02-17 18:46:52 +08:00
Soulter f22221f781 fix: improve permission denied message for local execution in Python and shell tools 2026-02-17 18:02:41 +08:00
Soulter 4250d997b3 feat: enhance provider sources panel with styled menu and mobile support 2026-02-17 16:14:35 +08:00
Soulter 153d8cef6b feat: add NVIDIA provider template (#5157)
fixes: #5156
2026-02-17 16:08:35 +08:00
Soulter c9cdf47603 chore: ruff format 2026-02-17 14:33:27 +08:00
Soulter 55ac878648 chore: bump version to 4.17.3 2026-02-17 14:09:10 +08:00
Soulter 60abddada3 fix: enhance handle_result to support event context and webchat image sending 2026-02-17 14:03:29 +08:00
Soulter bbc583cc8d fix: enhance plugin metadata handling by injecting attributes before instantiation (#5155) 2026-02-17 14:01:31 +08:00
Soulter 7906030037 fix: 'Plain' object has no attribute 'text' when using python 3.14 (#5154) 2026-02-17 13:51:25 +08:00
エイカク 06b385697d fix(desktop): include runtime deps for builtin plugins in backend build (#5146) 2026-02-17 11:43:19 +09:00
Raven95676 059008a903 fix: prevent updates for AstrBot launched via launcher 2026-02-17 09:33:45 +08:00
Soulter 97c9e95211 chore: ruff format 2026-02-17 02:31:38 +08:00
Soulter a4be369e43 chore: bump version to 4.17.1 2026-02-17 02:30:13 +08:00
Soulter bdaca78750 fix: add support for collecting data from builtin stars in electron pyinstaller build (#5145) 2026-02-17 02:27:07 +08:00
Soulter 6326d7e4ba fix: add MCP tools to function tool set in _plugin_tool_fix (#5144) 2026-02-17 02:19:36 +08:00
Soulter a809a09e55 docs: Added instructions for deploying AstrBot using AstrBot Launcher. (#5136)
Added instructions for deploying AstrBot using AstrBot Launcher.
2026-02-16 17:06:56 +08:00
Soulter 52c4ef2d87 chore: bump version to 4.17.1 2026-02-15 23:45:34 +08:00
Soulter 52c31fabe2 fix: update retention logic in LogManager to handle backup count correctly 2026-02-15 23:42:12 +08:00
NayukiMeko 79e239ad97 fix: handle list format content from OpenAI-compatible APIs (#5128)
* fix: handle list format content from OpenAI-compatible APIs

Some LLM providers (e.g., GLM-4.5V via SiliconFlow) return content as
list[dict] format like [{'type': 'text', 'text': '...'}] instead of
plain string. This causes the raw list representation to be displayed
to users.

Changes:
- Add _normalize_content() helper to extract text from various content formats
- Use json.loads instead of ast.literal_eval for safer parsing
- Add size limit check (8KB) before attempting JSON parsing
- Only convert lists that match OpenAI content-part schema (has 'type': 'text')
  to avoid collapsing legitimate list-literal replies like ['foo', 'bar']
- Add strip parameter to preserve whitespace in streaming chunks
- Clean up orphan </think> tags that may leak from some models

Fixes #5124

* fix: improve content normalization safety

- Try json.loads first, fallback to ast.literal_eval for single-quoted
  Python literals to avoid corrupting apostrophes (e.g., "don't")
- Coerce text values to str to handle null or non-string text fields
2026-02-15 23:30:47 +08:00
Soulter 8abaf1015d chore: bump version to 4.17.0 2026-02-15 21:51:00 +08:00
Soulter 9a0c814fd4 feat: add SSL configuration options for WebUI and update related logging (#5117) 2026-02-15 17:43:36 +08:00
Soulter c64e1b42a4 feat: replace colorlog with loguru for enhanced logging support (#5115) 2026-02-15 17:11:03 +08:00
Soulter 2d23c36067 feat: add Afdian support card to resources section in WelcomePage 2026-02-15 16:20:34 +08:00
Soulter 754144ad99 feat: add fallback chat model chain in tool loop runner (#5109)
* feat: implement fallback provider support for chat models and update configuration

* feat: enhance provider selection display with count and chips for selected providers

* feat: update fallback chat providers to use provider settings and add warning for non-list fallback models
2026-02-15 11:51:34 +08:00
Waterwzy 0faf109c2a feat: support hot reload after plugin load failure (#5043)
* add :Support hot reload after plugin load failure

* Apply suggestions from code review

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

* fix:reformat code

* fix:reformat code

---------

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2026-02-13 18:37:20 +08:00
evpeople 7d1eff3ec4 fix #5089: add uv lock step in Dockerfile before export (#5091)
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-02-13 18:34:26 +08:00
Soulter e295c470a5 fix: remove unnecessary frozen flag from requirements export in Dockerfile
fixes: #5089
2026-02-13 18:09:49 +08:00
Li-shi-ling 935168c024 fix-correct-FIRST_NOTICE.md-locale-path-resolution (#5083) (#5082)
* fix:修改配置文件目录

* fix:添加备选的FIRST_NOTICE.zh-CN.md用于兼容
2026-02-13 13:15:08 +08:00
Soulter f44961d065 feat: add LINE platform support with adapter and configuration (#5085) 2026-02-13 13:01:48 +08:00
Soulter 0c7a95ccd8 chore: bump version to 4.16.0 (#5074) 2026-02-12 22:55:42 +08:00
82 changed files with 3508 additions and 787 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.10'
python-version: '3.12'
- name: Install UV
run: pip install uv
+1
View File
@@ -22,6 +22,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN python -m pip install uv \
&& echo "3.12" > .python-version \
&& uv lock \
&& uv export --format requirements.txt --output-file requirements.txt --frozen \
&& uv pip install -r requirements.txt --no-cache-dir --system \
&& uv pip install socksio uv pilk --no-cache-dir --system
+8 -2
View File
@@ -81,6 +81,10 @@ uv tool install astrbot
astrbot
```
#### 启动器一键部署(AstrBot Launcher
进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
#### 宝塔面板部署
AstrBot 与宝塔面板合作,已上架至宝塔面板。
@@ -150,7 +154,8 @@ paru -S astrbot-git
**官方维护**
- QQ (官方平台 & OneBot)
- QQ
- OneBot v11 协议实现
- Telegram
- 企微应用 & 企微智能机器人
- 微信客服 & 微信公众号
@@ -158,10 +163,10 @@ paru -S astrbot-git
- 钉钉
- Slack
- Discord
- LINE
- Satori
- Misskey
- Whatsapp (将支持)
- LINE (将支持)
**社区维护**
@@ -181,6 +186,7 @@ paru -S astrbot-git
- DeepSeek
- Ollama (本地部署)
- LM Studio (本地部署)
- [AIHubMix](https://aihubmix.com/?aff=4bfH)
- [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
- [302.AI](https://share.302.ai/rr1M3l)
- [小马算力](https://www.tokenpony.cn/3YPyf)
+1 -1
View File
@@ -172,8 +172,8 @@ For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/REA
- Discord
- Satori
- Misskey
- LINE
- WhatsApp (Coming Soon)
- LINE (Coming Soon)
**Community Maintained**
+1 -1
View File
@@ -168,8 +168,8 @@ paru -S astrbot-git
- Discord
- Satori
- Misskey
- LINE
- WhatsApp (Bientôt disponible)
- LINE (Bientôt disponible)
**Maintenues par la communauté**
+1 -1
View File
@@ -168,8 +168,8 @@ paru -S astrbot-git
- Discord
- Satori
- Misskey
- LINE
- WhatsApp (近日対応予定)
- LINE (近日対応予定)
**コミュニティメンテナンス**
+2 -1
View File
@@ -158,8 +158,9 @@ paru -S astrbot-git
- Discord
- Satori
- Misskey
- LINE
- WhatsApp (Скоро)
- LINE (Скоро)
**Поддерживаемые сообществом**
+2 -1
View File
@@ -158,8 +158,9 @@ paru -S astrbot-git
- Discord
- Satori
- Misskey
- LINE
- Whatsapp(即將支援)
- LINE(即將支援)
**社群維護**
+2
View File
@@ -24,6 +24,7 @@ from astrbot.core.star.register import (
register_on_llm_tool_respond as on_llm_tool_respond,
)
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded
from astrbot.core.star.register import register_on_plugin_error as on_plugin_error
from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool
from astrbot.core.star.register import (
register_on_waiting_llm_request as on_waiting_llm_request,
@@ -52,6 +53,7 @@ __all__ = [
"on_decorating_result",
"on_llm_request",
"on_llm_response",
"on_plugin_error",
"on_platform_loaded",
"on_waiting_llm_request",
"permission_type",
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.15.0"
__version__ = "4.17.5"
@@ -91,6 +91,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
custom_token_counter: TokenCounter | None = None,
custom_compressor: ContextCompressor | None = None,
tool_schema_mode: str | None = "full",
fallback_providers: list[Provider] | None = None,
**kwargs: T.Any,
) -> None:
self.req = request
@@ -120,6 +121,17 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.context_manager = ContextManager(self.context_config)
self.provider = provider
self.fallback_providers: list[Provider] = []
seen_provider_ids: set[str] = {str(provider.provider_config.get("id", ""))}
for fallback_provider in fallback_providers or []:
fallback_id = str(fallback_provider.provider_config.get("id", ""))
if fallback_provider is provider:
continue
if fallback_id and fallback_id in seen_provider_ids:
continue
self.fallback_providers.append(fallback_provider)
if fallback_id:
seen_provider_ids.add(fallback_id)
self.final_llm_resp = None
self._state = AgentState.IDLE
self.tool_executor = tool_executor
@@ -166,16 +178,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.stats = AgentStats()
self.stats.start_time = time.time()
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
async def _iter_llm_responses(
self, *, include_model: bool = True
) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse."""
payload = {
"contexts": self.run_context.messages, # list[Message]
"func_tool": self.req.func_tool,
"model": self.req.model, # NOTE: in fact, this arg is None in most cases
"session_id": self.req.session_id,
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
}
if include_model:
# For primary provider we keep explicit model selection if provided.
payload["model"] = self.req.model
if self.streaming:
stream = self.provider.text_chat_stream(**payload)
async for resp in stream: # type: ignore
@@ -183,6 +198,77 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
else:
yield await self.provider.text_chat(**payload)
async def _iter_llm_responses_with_fallback(
self,
) -> T.AsyncGenerator[LLMResponse, None]:
"""Wrap _iter_llm_responses with provider fallback handling."""
candidates = [self.provider, *self.fallback_providers]
total_candidates = len(candidates)
last_exception: Exception | None = None
last_err_response: LLMResponse | None = None
for idx, candidate in enumerate(candidates):
candidate_id = candidate.provider_config.get("id", "<unknown>")
is_last_candidate = idx == total_candidates - 1
if idx > 0:
logger.warning(
"Switched from %s to fallback chat provider: %s",
self.provider.provider_config.get("id", "<unknown>"),
candidate_id,
)
self.provider = candidate
has_stream_output = False
try:
async for resp in self._iter_llm_responses(include_model=idx == 0):
if resp.is_chunk:
has_stream_output = True
yield resp
continue
if (
resp.role == "err"
and not has_stream_output
and (not is_last_candidate)
):
last_err_response = resp
logger.warning(
"Chat Model %s returns error response, trying fallback to next provider.",
candidate_id,
)
break
yield resp
return
if has_stream_output:
return
except Exception as exc: # noqa: BLE001
last_exception = exc
logger.warning(
"Chat Model %s request error: %s",
candidate_id,
exc,
exc_info=True,
)
continue
if last_err_response:
yield last_err_response
return
if last_exception:
yield LLMResponse(
role="err",
completion_text=(
"All chat models failed: "
f"{type(last_exception).__name__}: {last_exception}"
),
)
return
yield LLMResponse(
role="err",
completion_text="All available chat models are unavailable.",
)
def _simple_print_message_role(self, tag: str = ""):
roles = []
for message in self.run_context.messages:
@@ -215,7 +301,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
self._simple_print_message_role("[AftCompact]")
async for llm_response in self._iter_llm_responses():
async for llm_response in self._iter_llm_responses_with_fallback():
if llm_response.is_chunk:
# update ttft
if self.stats.time_to_first_token == 0:
@@ -271,6 +357,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
),
),
)
return
if not llm_resp.tools_call_name:
# 如果没有工具调用,转换到完成状态
+47
View File
@@ -42,6 +42,7 @@ from astrbot.core.message.components import File, Image, Reply
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.provider import Provider
from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.provider.manager import llm_tools
from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
from astrbot.core.star.context import Context
from astrbot.core.star.star_handler import star_map
@@ -769,6 +770,14 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
if plugin.name in event.plugins_name or plugin.reserved:
new_tool_set.add_tool(tool)
req.func_tool = new_tool_set
else:
# mcp tools
tool_set = req.func_tool
if not tool_set:
tool_set = ToolSet()
for tool in llm_tools.func_list:
if isinstance(tool, MCPTool):
tool_set.add_tool(tool)
async def _handle_webchat(
@@ -870,6 +879,41 @@ def _get_compress_provider(
return provider
def _get_fallback_chat_providers(
provider: Provider, plugin_context: Context, provider_settings: dict
) -> list[Provider]:
fallback_ids = provider_settings.get("fallback_chat_models", [])
if not isinstance(fallback_ids, list):
logger.warning(
"fallback_chat_models setting is not a list, skip fallback providers."
)
return []
provider_id = str(provider.provider_config.get("id", ""))
seen_provider_ids: set[str] = {provider_id} if provider_id else set()
fallbacks: list[Provider] = []
for fallback_id in fallback_ids:
if not isinstance(fallback_id, str) or not fallback_id:
continue
if fallback_id in seen_provider_ids:
continue
fallback_provider = plugin_context.get_provider_by_id(fallback_id)
if fallback_provider is None:
logger.warning("Fallback chat provider `%s` not found, skip.", fallback_id)
continue
if not isinstance(fallback_provider, Provider):
logger.warning(
"Fallback chat provider `%s` is invalid type: %s, skip.",
fallback_id,
type(fallback_provider),
)
continue
fallbacks.append(fallback_provider)
seen_provider_ids.add(fallback_id)
return fallbacks
async def build_main_agent(
*,
event: AstrMessageEvent,
@@ -1093,6 +1137,9 @@ async def build_main_agent(
truncate_turns=config.dequeue_context_length,
enforce_max_turns=config.max_context_length,
tool_schema_mode=config.tool_schema_mode,
fallback_providers=_get_fallback_chat_providers(
provider, plugin_context, config.provider_settings
),
)
if apply_reset:
+13 -6
View File
@@ -5,8 +5,9 @@ import mcp
from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent
from astrbot.core.computer.computer_client import get_booter, get_local_booter
from astrbot.core.message.message_event_result import MessageChain
param_schema = {
"type": "object",
@@ -25,7 +26,7 @@ param_schema = {
}
def handle_result(result: dict) -> ToolExecResult:
async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult:
data = result.get("data", {})
output = data.get("output", {})
error = data.get("error", "")
@@ -44,6 +45,9 @@ def handle_result(result: dict) -> ToolExecResult:
type="image", data=img["image/png"], mimeType="image/png"
)
)
if event.get_platform_name() == "webchat":
await event.send(message=MessageChain().base64_image(img["image/png"]))
if text:
resp.content.append(mcp.types.TextContent(type="text", text=text))
@@ -68,7 +72,7 @@ class PythonTool(FunctionTool):
)
try:
result = await sb.python.exec(code, silent=silent)
return handle_result(result)
return await handle_result(result, context.context.event)
except Exception as e:
return f"Error executing code: {str(e)}"
@@ -84,11 +88,14 @@ class LocalPythonTool(FunctionTool):
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult:
if context.context.event.role != "admin":
return "error: Permission denied. Local Python execution is only allowed for admin users. Tell user to set admins in AstrBot WebUI."
return (
"error: Permission denied. Local 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."
)
sb = get_local_booter()
try:
result = await sb.python.exec(code, silent=silent)
return handle_result(result)
return await handle_result(result, context.context.event)
except Exception as e:
return f"Error executing code: {str(e)}"
+5 -1
View File
@@ -47,7 +47,11 @@ class ExecuteShellTool(FunctionTool):
env: dict = {},
) -> ToolExecResult:
if 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."
return (
"error: Permission denied. Local 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."
)
if self.is_local:
sb = get_local_booter()
+83 -3
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.15.0"
VERSION = "4.17.5"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -15,6 +15,7 @@ WEBHOOK_SUPPORTED_PLATFORMS = [
"wecom_ai_bot",
"slack",
"lark",
"line",
]
# 默认配置
@@ -67,6 +68,7 @@ DEFAULT_CONFIG = {
"provider_settings": {
"enable": True,
"default_provider_id": "",
"fallback_chat_models": [],
"default_image_caption_provider_id": "",
"image_caption_prompt": "Please describe the image using Chinese.",
"provider_pool": ["*"], # "*" 表示使用所有可用的提供者
@@ -194,6 +196,12 @@ DEFAULT_CONFIG = {
"host": "0.0.0.0",
"port": 6185,
"disable_access_log": True,
"ssl": {
"enable": False,
"cert_file": "",
"key_file": "",
"ca_certs": "",
},
},
"platform": [],
"platform_specific": {
@@ -415,6 +423,7 @@ CONFIG_METADATA_2 = {
"slack_webhook_port": 6197,
"slack_webhook_path": "/astrbot-slack-webhook/callback",
},
# LINE's config is located in line_adapter.py
"Satori": {
"id": "satori",
"type": "satori",
@@ -1020,6 +1029,30 @@ CONFIG_METADATA_2 = {
"proxy": "",
"custom_headers": {},
},
"AIHubMix": {
"id": "aihubmix",
"provider": "aihubmix",
"type": "aihubmix_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://aihubmix.com/v1",
"proxy": "",
"custom_headers": {},
},
"NVIDIA": {
"id": "nvidia",
"provider": "nvidia",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://integrate.api.nvidia.com/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"Azure OpenAI": {
"id": "azure_openai",
"provider": "azure",
@@ -2205,6 +2238,10 @@ CONFIG_METADATA_2 = {
"default_provider_id": {
"type": "string",
},
"fallback_chat_models": {
"type": "list",
"items": {"type": "string"},
},
"wake_prefix": {
"type": "string",
},
@@ -2399,6 +2436,19 @@ CONFIG_METADATA_2 = {
"type": "string",
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
},
"dashboard.ssl.enable": {"type": "bool"},
"dashboard.ssl.cert_file": {
"type": "string",
"condition": {"dashboard.ssl.enable": True},
},
"dashboard.ssl.key_file": {
"type": "string",
"condition": {"dashboard.ssl.enable": True},
},
"dashboard.ssl.ca_certs": {
"type": "string",
"condition": {"dashboard.ssl.enable": True},
},
"log_file_enable": {"type": "bool"},
"log_file_path": {"type": "string", "condition": {"log_file_enable": True}},
"log_file_max_mb": {"type": "int", "condition": {"log_file_enable": True}},
@@ -2502,15 +2552,22 @@ CONFIG_METADATA_3 = {
},
"ai": {
"description": "模型",
"hint": "当使用非内置 Agent 执行器时,默认聊天模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。",
"hint": "当使用非内置 Agent 执行器时,默认对话模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。",
"type": "object",
"items": {
"provider_settings.default_provider_id": {
"description": "默认聊天模型",
"description": "默认对话模型",
"type": "string",
"_special": "select_provider",
"hint": "留空时使用第一个模型",
},
"provider_settings.fallback_chat_models": {
"description": "回退对话模型列表",
"type": "list",
"items": {"type": "string"},
"_special": "select_providers",
"hint": "主聊天模型请求失败时,按顺序切换到这些模型。",
},
"provider_settings.default_image_caption_provider_id": {
"description": "默认图片转述模型",
"type": "string",
@@ -3406,6 +3463,29 @@ CONFIG_METADATA_3_SYSTEM = {
"hint": "控制台输出日志的级别。",
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
},
"dashboard.ssl.enable": {
"description": "启用 WebUI HTTPS",
"type": "bool",
"hint": "启用后,WebUI 将直接使用 HTTPS 提供服务。",
},
"dashboard.ssl.cert_file": {
"description": "SSL 证书文件路径",
"type": "string",
"hint": "证书文件路径(PEM)。支持绝对路径和相对路径(相对于当前工作目录)。",
"condition": {"dashboard.ssl.enable": True},
},
"dashboard.ssl.key_file": {
"description": "SSL 私钥文件路径",
"type": "string",
"hint": "私钥文件路径(PEM)。支持绝对路径和相对路径(相对于当前工作目录)。",
"condition": {"dashboard.ssl.enable": True},
},
"dashboard.ssl.ca_certs": {
"description": "SSL CA 证书文件路径",
"type": "string",
"hint": "可选。用于指定 CA 证书文件路径。",
"condition": {"dashboard.ssl.enable": True},
},
"log_file_enable": {
"description": "启用文件日志",
"type": "bool",
+266 -309
View File
@@ -1,24 +1,4 @@
"""日志系统, 用于支持核心组件和插件的日志记录, 提供了日志订阅功能
const:
CACHED_SIZE: 日志缓存大小, 用于限制缓存的日志数量
log_color_config: 日志颜色配置, 定义了不同日志级别的颜色
class:
LogBroker: 日志代理类, 用于缓存和分发日志消息
LogQueueHandler: 日志处理器, 用于将日志消息发送到 LogBroker
LogManager: 日志管理器, 用于创建和配置日志记录器
function:
is_plugin_path: 检查文件路径是否来自插件目录
get_short_level_name: 将日志级别名称转换为四个字母的缩写
工作流程:
1. 通过 LogManager.GetLogger() 获取日志器, 配置了控制台输出和多个格式化过滤器
2. 通过 set_queue_handler() 设置日志处理器, 将日志消息发送到 LogBroker
3. logBroker 维护一个订阅者列表, 负责将日志分发给所有订阅者
4. 订阅者可以使用 register() 方法注册到 LogBroker, 订阅日志流
"""
"""日志系统,统一将标准 logging 输出转发到 loguru。"""
import asyncio
import logging
@@ -27,54 +7,59 @@ import sys
import time
from asyncio import Queue
from collections import deque
from logging.handlers import RotatingFileHandler
from typing import TYPE_CHECKING
import colorlog
from loguru import logger as _raw_loguru_logger
from astrbot.core.config.default import VERSION
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
# 日志缓存大小
CACHED_SIZE = 500
# 日志颜色配置
log_color_config = {
"DEBUG": "green",
"INFO": "bold_cyan",
"WARNING": "bold_yellow",
"ERROR": "red",
"CRITICAL": "bold_red",
"RESET": "reset",
"asctime": "green",
}
if TYPE_CHECKING:
from loguru import Record
def is_plugin_path(pathname):
"""检查文件路径是否来自插件目录
class _RecordEnricherFilter(logging.Filter):
"""为 logging.LogRecord 注入 AstrBot 日志字段。"""
Args:
pathname (str): 文件路径
def filter(self, record: logging.LogRecord) -> bool:
record.plugin_tag = "[Plug]" if _is_plugin_path(record.pathname) else "[Core]"
record.short_levelname = _get_short_level_name(record.levelname)
record.astrbot_version_tag = (
f" [v{VERSION}]" if record.levelno >= logging.WARNING else ""
)
record.source_file = _build_source_file(record.pathname)
record.source_line = record.lineno
record.is_trace = record.name == "astrbot.trace"
return True
Returns:
bool: 如果路径来自插件目录,则返回 True,否则返回 False
"""
class _QueueAnsiColorFilter(logging.Filter):
"""Attach ANSI color prefix for WebUI console rendering."""
_LEVEL_COLOR = {
"DEBUG": "\u001b[1;34m",
"INFO": "\u001b[1;36m",
"WARNING": "\u001b[1;33m",
"ERROR": "\u001b[31m",
"CRITICAL": "\u001b[1;31m",
}
def filter(self, record: logging.LogRecord) -> bool:
record.ansi_prefix = self._LEVEL_COLOR.get(record.levelname, "\u001b[0m")
record.ansi_reset = "\u001b[0m"
return True
def _is_plugin_path(pathname: str | None) -> bool:
if not pathname:
return False
norm_path = os.path.normpath(pathname)
return ("data/plugins" in norm_path) or ("astrbot/builtin_stars/" in norm_path)
def get_short_level_name(level_name):
"""将日志级别名称转换为四个字母的缩写
Args:
level_name (str): 日志级别名称, 如 "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"
Returns:
str: 四个字母的日志级别缩写
"""
def _get_short_level_name(level_name: str) -> str:
level_map = {
"DEBUG": "DBUG",
"INFO": "INFO",
@@ -85,44 +70,75 @@ def get_short_level_name(level_name):
return level_map.get(level_name, level_name[:4].upper())
class LogBroker:
"""日志代理类, 用于缓存和分发日志消息
def _build_source_file(pathname: str | None) -> str:
if not pathname:
return "unknown"
dirname = os.path.dirname(pathname)
return (
os.path.basename(dirname) + "." + os.path.basename(pathname).replace(".py", "")
)
发布-订阅模式
"""
def _patch_record(record: "Record") -> None:
extra = record["extra"]
extra.setdefault("plugin_tag", "[Core]")
extra.setdefault("short_levelname", _get_short_level_name(record["level"].name))
level_no = record["level"].no
extra.setdefault("astrbot_version_tag", f" [v{VERSION}]" if level_no >= 30 else "")
extra.setdefault("source_file", _build_source_file(record["file"].path))
extra.setdefault("source_line", record["line"])
extra.setdefault("is_trace", False)
_loguru = _raw_loguru_logger.patch(_patch_record)
class _LoguruInterceptHandler(logging.Handler):
"""将 logging 记录转发到 loguru。"""
def emit(self, record: logging.LogRecord) -> None:
try:
level: str | int = _loguru.level(record.levelname).name
except ValueError:
level = record.levelno
payload = {
"plugin_tag": getattr(record, "plugin_tag", "[Core]"),
"short_levelname": getattr(
record,
"short_levelname",
_get_short_level_name(record.levelname),
),
"astrbot_version_tag": getattr(record, "astrbot_version_tag", ""),
"source_file": getattr(
record, "source_file", _build_source_file(record.pathname)
),
"source_line": getattr(record, "source_line", record.lineno),
"is_trace": getattr(record, "is_trace", record.name == "astrbot.trace"),
}
_loguru.bind(**payload).opt(exception=record.exc_info).log(
level,
record.getMessage(),
)
class LogBroker:
"""日志代理类,用于缓存和分发日志消息。"""
def __init__(self) -> None:
self.log_cache = deque(maxlen=CACHED_SIZE) # 环形缓冲区, 保存最近的日志
self.subscribers: list[Queue] = [] # 订阅者列表
self.log_cache = deque(maxlen=CACHED_SIZE)
self.subscribers: list[Queue] = []
def register(self) -> Queue:
"""注册新的订阅者, 并给每个订阅者返回一个带有日志缓存的队列
Returns:
Queue: 订阅者的队列, 可用于接收日志消息
"""
q = Queue(maxsize=CACHED_SIZE + 10)
self.subscribers.append(q)
return q
def unregister(self, q: Queue) -> None:
"""取消订阅
Args:
q (Queue): 需要取消订阅的队列
"""
self.subscribers.remove(q)
def publish(self, log_entry: dict) -> None:
"""发布新日志到所有订阅者, 使用非阻塞方式投递, 避免一个订阅者阻塞整个系统
Args:
log_entry (dict): 日志消息, 包含日志级别和日志内容.
example: {"level": "INFO", "data": "This is a log message.", "time": "2023-10-01 12:00:00"}
"""
self.log_cache.append(log_entry)
for q in self.subscribers:
try:
@@ -132,23 +148,13 @@ class LogBroker:
class LogQueueHandler(logging.Handler):
"""日志处理器, 用于将日志消息发送到 LogBroker
继承自 logging.Handler
"""
"""日志处理器用于将日志消息发送到 LogBroker"""
def __init__(self, log_broker: LogBroker) -> None:
super().__init__()
self.log_broker = log_broker
def emit(self, record) -> None:
"""日志处理的入口方法, 接受一个日志记录, 转换为字符串后由 LogBroker 发布
这个方法会在每次日志记录时被调用
Args:
record (logging.LogRecord): 日志记录对象, 包含日志信息
"""
def emit(self, record: logging.LogRecord) -> None:
log_entry = self.format(record)
self.log_broker.publish(
{
@@ -160,117 +166,16 @@ class LogQueueHandler(logging.Handler):
class LogManager:
"""日志管理器, 用于创建和配置日志记录器
_LOGGER_HANDLER_FLAG = "_astrbot_loguru_handler"
_ENRICH_FILTER_FLAG = "_astrbot_enrich_filter"
提供了获取默认日志记录器logger和设置队列处理器的方法
"""
_FILE_HANDLER_FLAG = "_astrbot_file_handler"
_TRACE_FILE_HANDLER_FLAG = "_astrbot_trace_file_handler"
@classmethod
def GetLogger(cls, log_name: str = "default"):
"""获取指定名称的日志记录器logger
Args:
log_name (str): 日志记录器的名称, 默认为 "default"
Returns:
logging.Logger: 返回配置好的日志记录器
"""
logger = logging.getLogger(log_name)
# 检查该logger或父级logger是否已经有处理器, 如果已经有处理器, 直接返回该logger, 避免重复配置
if logger.hasHandlers():
return logger
# 如果logger没有处理器
console_handler = logging.StreamHandler(
sys.stdout,
) # 创建一个StreamHandler用于控制台输出
console_handler.setLevel(
logging.DEBUG,
) # 将日志级别设置为DEBUG(最低级别, 显示所有日志), *如果插件没有设置级别, 默认为DEBUG
# 创建彩色日志格式化器, 输出日志格式为: [时间] [插件标签] [日志级别] [文件名:行号]: 日志消息
console_formatter = colorlog.ColoredFormatter(
fmt="%(log_color)s [%(asctime)s] %(plugin_tag)s [%(short_levelname)-4s]%(astrbot_version_tag)s [%(filename)s:%(lineno)d]: %(message)s %(reset)s",
datefmt="%H:%M:%S",
log_colors=log_color_config,
)
class PluginFilter(logging.Filter):
"""插件过滤器类, 用于标记日志来源是插件还是核心组件"""
def filter(self, record) -> bool:
record.plugin_tag = (
"[Plug]" if is_plugin_path(record.pathname) else "[Core]"
)
return True
class FileNameFilter(logging.Filter):
"""文件名过滤器类, 用于修改日志记录的文件名格式
例如: 将文件路径 /path/to/file.py 转换为 file.<file> 格式
"""
# 获取这个文件和父文件夹的名字:<folder>.<file> 并且去除 .py
def filter(self, record) -> bool:
dirname = os.path.dirname(record.pathname)
record.filename = (
os.path.basename(dirname)
+ "."
+ os.path.basename(record.pathname).replace(".py", "")
)
return True
class LevelNameFilter(logging.Filter):
"""短日志级别名称过滤器类, 用于将日志级别名称转换为四个字母的缩写"""
# 添加短日志级别名称
def filter(self, record) -> bool:
record.short_levelname = get_short_level_name(record.levelname)
return True
class AstrBotVersionTagFilter(logging.Filter):
"""在 WARNING 及以上级别日志后追加当前 AstrBot 版本号。"""
def filter(self, record) -> bool:
if record.levelno >= logging.WARNING:
record.astrbot_version_tag = f" [v{VERSION}]"
else:
record.astrbot_version_tag = ""
return True
console_handler.setFormatter(console_formatter) # 设置处理器的格式化器
logger.addFilter(PluginFilter()) # 添加插件过滤器
logger.addFilter(FileNameFilter()) # 添加文件名过滤器
logger.addFilter(LevelNameFilter()) # 添加级别名称过滤器
logger.addFilter(AstrBotVersionTagFilter()) # 追加版本号(WARNING 及以上)
logger.setLevel(logging.DEBUG) # 设置日志级别为DEBUG
logger.addHandler(console_handler) # 添加处理器到logger
return logger
@classmethod
def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker) -> None:
"""设置队列处理器, 用于将日志消息发送到 LogBroker
Args:
logger (logging.Logger): 日志记录器
log_broker (LogBroker): 日志代理类, 用于缓存和分发日志消息
"""
handler = LogQueueHandler(log_broker)
handler.setLevel(logging.DEBUG)
if logger.handlers:
handler.setFormatter(logger.handlers[0].formatter)
else:
# 为队列处理器设置相同格式的formatter
handler.setFormatter(
logging.Formatter(
"[%(asctime)s] [%(short_levelname)s] %(plugin_tag)s[%(filename)s:%(lineno)d]: %(message)s",
),
)
logger.addHandler(handler)
_configured = False
_console_sink_id: int | None = None
_file_sink_id: int | None = None
_trace_sink_id: int | None = None
_NOISY_LOGGER_LEVELS: dict[str, int] = {
"aiosqlite": logging.WARNING,
}
@classmethod
def _default_log_path(cls) -> str:
@@ -285,79 +190,147 @@ class LogManager:
return os.path.join(get_astrbot_data_path(), configured_path)
@classmethod
def _get_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]:
return [
handler
for handler in logger.handlers
if getattr(handler, cls._FILE_HANDLER_FLAG, False)
]
def _setup_loguru(cls) -> None:
if cls._configured:
return
@classmethod
def _get_trace_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]:
return [
handler
for handler in logger.handlers
if getattr(handler, cls._TRACE_FILE_HANDLER_FLAG, False)
]
@classmethod
def _remove_file_handlers(cls, logger: logging.Logger) -> None:
for handler in cls._get_file_handlers(logger):
logger.removeHandler(handler)
try:
handler.close()
except Exception:
pass
@classmethod
def _remove_trace_file_handlers(cls, logger: logging.Logger) -> None:
for handler in cls._get_trace_file_handlers(logger):
logger.removeHandler(handler)
try:
handler.close()
except Exception:
pass
@classmethod
def _add_file_handler(
cls,
logger: logging.Logger,
file_path: str,
max_mb: int | None = None,
backup_count: int = 3,
trace: bool = False,
) -> None:
os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
max_bytes = 0
if max_mb and max_mb > 0:
max_bytes = max_mb * 1024 * 1024
if max_bytes > 0:
file_handler = RotatingFileHandler(
file_path,
maxBytes=max_bytes,
backupCount=backup_count,
encoding="utf-8",
)
else:
file_handler = logging.FileHandler(file_path, encoding="utf-8")
file_handler.setLevel(logger.level)
if trace:
formatter = logging.Formatter(
"[%(asctime)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
else:
formatter = logging.Formatter(
"[%(asctime)s] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s [%(filename)s:%(lineno)d]: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler.setFormatter(formatter)
setattr(
file_handler,
cls._TRACE_FILE_HANDLER_FLAG if trace else cls._FILE_HANDLER_FLAG,
True,
_loguru.remove()
cls._console_sink_id = _loguru.add(
sys.stdout,
level="DEBUG",
colorize=True,
filter=lambda record: not record["extra"].get("is_trace", False),
format=(
"<green>[{time:HH:mm:ss.SSS}]</green> {extra[plugin_tag]} "
"<level>[{extra[short_levelname]}]</level>{extra[astrbot_version_tag]} "
"[{extra[source_file]}:{extra[source_line]}]: <level>{message}</level>"
),
)
cls._configured = True
@classmethod
def _setup_root_bridge(cls) -> None:
root_logger = logging.getLogger()
has_handler = any(
getattr(handler, cls._LOGGER_HANDLER_FLAG, False)
for handler in root_logger.handlers
)
if not has_handler:
handler = _LoguruInterceptHandler()
setattr(handler, cls._LOGGER_HANDLER_FLAG, True)
root_logger.addHandler(handler)
root_logger.setLevel(logging.DEBUG)
for name, level in cls._NOISY_LOGGER_LEVELS.items():
logging.getLogger(name).setLevel(level)
@classmethod
def _ensure_logger_enricher_filter(cls, logger: logging.Logger) -> None:
has_filter = any(
getattr(existing_filter, cls._ENRICH_FILTER_FLAG, False)
for existing_filter in logger.filters
)
if not has_filter:
enrich_filter = _RecordEnricherFilter()
setattr(enrich_filter, cls._ENRICH_FILTER_FLAG, True)
logger.addFilter(enrich_filter)
@classmethod
def _ensure_logger_intercept_handler(cls, logger: logging.Logger) -> None:
has_handler = any(
getattr(handler, cls._LOGGER_HANDLER_FLAG, False)
for handler in logger.handlers
)
if not has_handler:
handler = _LoguruInterceptHandler()
setattr(handler, cls._LOGGER_HANDLER_FLAG, True)
logger.addHandler(handler)
@classmethod
def GetLogger(cls, log_name: str = "default") -> logging.Logger:
cls._setup_loguru()
cls._setup_root_bridge()
logger = logging.getLogger(log_name)
cls._ensure_logger_enricher_filter(logger)
cls._ensure_logger_intercept_handler(logger)
logger.setLevel(logging.DEBUG)
logger.propagate = False
return logger
@classmethod
def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker) -> None:
cls._ensure_logger_enricher_filter(logger)
for handler in logger.handlers:
if isinstance(handler, LogQueueHandler):
return
handler = LogQueueHandler(log_broker)
handler.setLevel(logging.DEBUG)
handler.addFilter(_QueueAnsiColorFilter())
handler.setFormatter(
logging.Formatter(
"%(ansi_prefix)s[%(asctime)s.%(msecs)03d] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s "
"[%(source_file)s:%(source_line)d]: %(message)s%(ansi_reset)s",
datefmt="%Y-%m-%d %H:%M:%S",
),
)
logger.addHandler(handler)
@classmethod
def _remove_sink(cls, sink_id: int | None) -> None:
if sink_id is None:
return
try:
_loguru.remove(sink_id)
except ValueError:
pass
@classmethod
def _add_file_sink(
cls,
*,
file_path: str,
level: int,
max_mb: int | None,
backup_count: int,
trace: bool,
) -> int:
os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
rotation = f"{max_mb} MB" if max_mb and max_mb > 0 else None
retention = (
backup_count if rotation and backup_count and backup_count > 0 else None
)
if trace:
return _loguru.add(
file_path,
level="INFO",
format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {message}",
encoding="utf-8",
rotation=rotation,
retention=retention,
enqueue=True,
filter=lambda record: record["extra"].get("is_trace", False),
)
logging_level_name = logging.getLevelName(level)
if isinstance(logging_level_name, int):
logging_level_name = "INFO"
return _loguru.add(
file_path,
level=logging_level_name,
format=(
"[{time:YYYY-MM-DD HH:mm:ss.SSS}] {extra[plugin_tag]} "
"[{extra[short_levelname]}]{extra[astrbot_version_tag]} "
"[{extra[source_file]}:{extra[source_line]}]: {message}"
),
encoding="utf-8",
rotation=rotation,
retention=retention,
enqueue=True,
filter=lambda record: not record["extra"].get("is_trace", False),
)
logger.addHandler(file_handler)
@classmethod
def configure_logger(
@@ -366,13 +339,6 @@ class LogManager:
config: dict | None,
override_level: str | None = None,
) -> None:
"""根据配置设置日志级别和文件日志。
Args:
logger: 需要配置的 logger
config: 配置字典
override_level: 若提供,将覆盖配置中的日志级别
"""
if not config:
return
@@ -383,7 +349,6 @@ class LogManager:
except Exception:
logger.setLevel(logging.INFO)
# 兼容旧版嵌套配置
if "log_file" in config:
file_conf = config.get("log_file") or {}
enable_file = bool(file_conf.get("enable", False))
@@ -394,27 +359,25 @@ class LogManager:
file_path = config.get("log_file_path")
max_mb = config.get("log_file_max_mb")
file_path = cls._resolve_log_path(file_path)
cls._remove_sink(cls._file_sink_id)
cls._file_sink_id = None
existing = cls._get_file_handlers(logger)
if not enable_file:
cls._remove_file_handlers(logger)
return
# 如果已有文件处理器且路径一致,则仅同步级别
if existing:
handler = existing[0]
base = getattr(handler, "baseFilename", "")
if base and os.path.abspath(base) == os.path.abspath(file_path):
handler.setLevel(logger.level)
return
cls._remove_file_handlers(logger)
cls._add_file_handler(logger, file_path, max_mb=max_mb)
try:
cls._file_sink_id = cls._add_file_sink(
file_path=cls._resolve_log_path(file_path),
level=logger.level,
max_mb=max_mb,
backup_count=3,
trace=False,
)
except Exception as e:
logger.error(f"Failed to add file sink: {e}")
@classmethod
def configure_trace_logger(cls, config: dict | None) -> None:
"""为 trace 事件配置独立的文件日志,不向控制台输出。"""
if not config:
return
@@ -429,28 +392,22 @@ class LogManager:
path = path or legacy.get("trace_path")
max_mb = max_mb or legacy.get("trace_max_mb")
if not enable:
trace_logger = logging.getLogger("astrbot.trace")
cls._remove_trace_file_handlers(trace_logger)
return
file_path = cls._resolve_log_path(path or "logs/astrbot.trace.log")
trace_logger = logging.getLogger("astrbot.trace")
cls._ensure_logger_enricher_filter(trace_logger)
cls._ensure_logger_intercept_handler(trace_logger)
trace_logger.setLevel(logging.INFO)
trace_logger.propagate = False
existing = cls._get_trace_file_handlers(trace_logger)
if existing:
handler = existing[0]
base = getattr(handler, "baseFilename", "")
if base and os.path.abspath(base) == os.path.abspath(file_path):
handler.setLevel(trace_logger.level)
return
cls._remove_trace_file_handlers(trace_logger)
cls._remove_sink(cls._trace_sink_id)
cls._trace_sink_id = None
cls._add_file_handler(
trace_logger,
file_path,
if not enable:
return
cls._trace_sink_id = cls._add_file_sink(
file_path=cls._resolve_log_path(path or "logs/astrbot.trace.log"),
level=logging.INFO,
max_mb=max_mb,
backup_count=3,
trace=True,
)
+28 -22
View File
@@ -25,10 +25,14 @@ import asyncio
import base64
import json
import os
import sys
import uuid
from enum import Enum
from pydantic.v1 import BaseModel
if sys.version_info >= (3, 14):
from pydantic import BaseModel
else:
from pydantic.v1 import BaseModel
from astrbot.core import astrbot_config, file_token_service, logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
@@ -85,7 +89,7 @@ class BaseMessageComponent(BaseModel):
class Plain(BaseMessageComponent):
type = ComponentType.Plain
type: ComponentType = ComponentType.Plain
text: str
convert: bool | None = True
@@ -100,7 +104,7 @@ class Plain(BaseMessageComponent):
class Face(BaseMessageComponent):
type = ComponentType.Face
type: ComponentType = ComponentType.Face
id: int
def __init__(self, **_) -> None:
@@ -108,13 +112,15 @@ class Face(BaseMessageComponent):
class Record(BaseMessageComponent):
type = ComponentType.Record
type: ComponentType = ComponentType.Record
file: str | None = ""
magic: bool | None = False
url: str | None = ""
cache: bool | None = True
proxy: bool | None = True
timeout: int | None = 0
# Original text content (e.g. TTS source text), used as caption in fallback scenarios
text: str | None = None
# 额外
path: str | None
@@ -215,7 +221,7 @@ class Record(BaseMessageComponent):
class Video(BaseMessageComponent):
type = ComponentType.Video
type: ComponentType = ComponentType.Video
file: str
cover: str | None = ""
c: int | None = 2
@@ -301,7 +307,7 @@ class Video(BaseMessageComponent):
class At(BaseMessageComponent):
type = ComponentType.At
type: ComponentType = ComponentType.At
qq: int | str # 此处str为all时代表所有人
name: str | None = ""
@@ -323,28 +329,28 @@ class AtAll(At):
class RPS(BaseMessageComponent): # TODO
type = ComponentType.RPS
type: ComponentType = ComponentType.RPS
def __init__(self, **_) -> None:
super().__init__(**_)
class Dice(BaseMessageComponent): # TODO
type = ComponentType.Dice
type: ComponentType = ComponentType.Dice
def __init__(self, **_) -> None:
super().__init__(**_)
class Shake(BaseMessageComponent): # TODO
type = ComponentType.Shake
type: ComponentType = ComponentType.Shake
def __init__(self, **_) -> None:
super().__init__(**_)
class Share(BaseMessageComponent):
type = ComponentType.Share
type: ComponentType = ComponentType.Share
url: str
title: str
content: str | None = ""
@@ -355,7 +361,7 @@ class Share(BaseMessageComponent):
class Contact(BaseMessageComponent): # TODO
type = ComponentType.Contact
type: ComponentType = ComponentType.Contact
_type: str # type 字段冲突
id: int | None = 0
@@ -364,7 +370,7 @@ class Contact(BaseMessageComponent): # TODO
class Location(BaseMessageComponent): # TODO
type = ComponentType.Location
type: ComponentType = ComponentType.Location
lat: float
lon: float
title: str | None = ""
@@ -375,7 +381,7 @@ class Location(BaseMessageComponent): # TODO
class Music(BaseMessageComponent):
type = ComponentType.Music
type: ComponentType = ComponentType.Music
_type: str
id: int | None = 0
url: str | None = ""
@@ -392,7 +398,7 @@ class Music(BaseMessageComponent):
class Image(BaseMessageComponent):
type = ComponentType.Image
type: ComponentType = ComponentType.Image
file: str | None = ""
_type: str | None = ""
subType: int | None = 0
@@ -507,7 +513,7 @@ class Image(BaseMessageComponent):
class Reply(BaseMessageComponent):
type = ComponentType.Reply
type: ComponentType = ComponentType.Reply
id: str | int
"""所引用的消息 ID"""
chain: list["BaseMessageComponent"] | None = []
@@ -543,7 +549,7 @@ class Poke(BaseMessageComponent):
class Forward(BaseMessageComponent):
type = ComponentType.Forward
type: ComponentType = ComponentType.Forward
id: str
def __init__(self, **_) -> None:
@@ -553,7 +559,7 @@ class Forward(BaseMessageComponent):
class Node(BaseMessageComponent):
"""群合并转发消息"""
type = ComponentType.Node
type: ComponentType = ComponentType.Node
id: int | None = 0 # 忽略
name: str | None = "" # qq昵称
uin: str | None = "0" # qq号
@@ -605,7 +611,7 @@ class Node(BaseMessageComponent):
class Nodes(BaseMessageComponent):
type = ComponentType.Nodes
type: ComponentType = ComponentType.Nodes
nodes: list[Node]
def __init__(self, nodes: list[Node], **_) -> None:
@@ -631,7 +637,7 @@ class Nodes(BaseMessageComponent):
class Json(BaseMessageComponent):
type = ComponentType.Json
type: ComponentType = ComponentType.Json
data: dict
def __init__(self, data: str | dict, **_) -> None:
@@ -641,14 +647,14 @@ class Json(BaseMessageComponent):
class Unknown(BaseMessageComponent):
type = ComponentType.Unknown
type: ComponentType = ComponentType.Unknown
text: str
class File(BaseMessageComponent):
"""文件消息段"""
type = ComponentType.File
type: ComponentType = ComponentType.File
name: str | None = "" # 名字
file_: str | None = "" # 本地路径
url: str | None = "" # url
@@ -783,7 +789,7 @@ class File(BaseMessageComponent):
class WechatEmoji(BaseMessageComponent):
type = ComponentType.WechatEmoji
type: ComponentType = ComponentType.WechatEmoji
md5: str | None = ""
md5_len: int | None = 0
cdnurl: str | None = ""
@@ -8,9 +8,9 @@ from astrbot.core import logger
from astrbot.core.message.message_event_result import MessageEventResult
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import StarHandlerMetadata
from astrbot.core.star.star_handler import EventType, StarHandlerMetadata
from ...context import PipelineContext, call_handler
from ...context import PipelineContext, call_event_hook, call_handler
from ..stage import Stage
@@ -48,10 +48,20 @@ class StarRequestSubStage(Stage):
yield ret
event.clear_result() # 清除上一个 handler 的结果
except Exception as e:
logger.error(traceback.format_exc())
traceback_text = traceback.format_exc()
logger.error(traceback_text)
logger.error(f"Star {handler.handler_full_name} handle error: {e}")
if event.is_at_or_wake_command:
await call_event_hook(
event,
EventType.OnPluginErrorEvent,
md.name,
handler.handler_name,
e,
traceback_text,
)
if not event.is_stopped() and event.is_at_or_wake_command:
ret = f":(\n\n在调用插件 {md.name} 的处理函数 {handler.handler_name} 时出现异常:{e}"
event.set_result(MessageEventResult().message(ret))
yield
@@ -315,6 +315,7 @@ class ResultDecorateStage(Stage):
Record(
file=url or audio_path,
url=url or audio_path,
text=comp.text,
),
)
if dual_output:
+4
View File
@@ -176,6 +176,10 @@ class PlatformManager:
from .sources.satori.satori_adapter import (
SatoriPlatformAdapter, # noqa: F401
)
case "line":
from .sources.line.line_adapter import (
LinePlatformAdapter, # noqa: F401
)
except (ImportError, ModuleNotFoundError) as e:
logger.error(
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
@@ -0,0 +1,474 @@
import asyncio
import mimetypes
import time
import uuid
from pathlib import Path
from typing import Any, cast
from astrbot.api import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
from astrbot.api.platform import (
AstrBotMessage,
Group,
MessageMember,
MessageType,
Platform,
PlatformMetadata,
)
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.webhook_utils import log_webhook_info
from ...register import register_platform_adapter
from .line_api import LineAPIClient
from .line_event import LineMessageEvent
LINE_CONFIG_METADATA = {
"channel_access_token": {
"description": "LINE Channel Access Token",
"type": "string",
"hint": "LINE Messaging API 的 channel access token。",
},
"channel_secret": {
"description": "LINE Channel Secret",
"type": "string",
"hint": "用于校验 LINE Webhook 签名。",
},
}
LINE_I18N_RESOURCES = {
"zh-CN": {
"channel_access_token": {
"description": "LINE Channel Access Token",
"hint": "LINE Messaging API 的 channel access token。",
},
"channel_secret": {
"description": "LINE Channel Secret",
"hint": "用于校验 LINE Webhook 签名。",
},
},
"en-US": {
"channel_access_token": {
"description": "LINE Channel Access Token",
"hint": "Channel access token for LINE Messaging API.",
},
"channel_secret": {
"description": "LINE Channel Secret",
"hint": "Used to verify LINE webhook signatures.",
},
},
}
@register_platform_adapter(
"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,
)
class LinePlatformAdapter(Platform):
def __init__(
self,
platform_config: dict,
platform_settings: dict,
event_queue: asyncio.Queue,
) -> None:
super().__init__(platform_config, event_queue)
self.config["unified_webhook_mode"] = True
self.destination = "unknown"
self.settings = platform_settings
self._event_id_timestamps: dict[str, float] = {}
self.shutdown_event = asyncio.Event()
channel_access_token = str(platform_config.get("channel_access_token", ""))
channel_secret = str(platform_config.get("channel_secret", ""))
if not channel_access_token or not channel_secret:
raise ValueError(
"LINE 适配器需要 channel_access_token 和 channel_secret。",
)
self.line_api = LineAPIClient(
channel_access_token=channel_access_token,
channel_secret=channel_secret,
)
async def send_by_session(
self,
session: MessageSesion,
message_chain: MessageChain,
) -> None:
messages = await LineMessageEvent.build_line_messages(message_chain)
if messages:
await self.line_api.push_message(session.session_id, messages)
await super().send_by_session(session, message_chain)
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
name="line",
description="LINE Messaging API 适配器",
id=cast(str, self.config.get("id", "line")),
support_streaming_message=False,
)
async def run(self) -> None:
webhook_uuid = self.config.get("webhook_uuid")
if webhook_uuid:
log_webhook_info(f"{self.meta().id}(LINE)", webhook_uuid)
else:
logger.warning("[LINE] webhook_uuid 为空,统一 Webhook 可能无法接收消息。")
await self.shutdown_event.wait()
async def terminate(self) -> None:
self.shutdown_event.set()
await self.line_api.close()
async def webhook_callback(self, request: Any) -> Any:
raw_body = await request.get_data()
signature = request.headers.get("x-line-signature")
if not self.line_api.verify_signature(raw_body, signature):
logger.warning("[LINE] invalid webhook signature")
return "invalid signature", 400
try:
payload = await request.get_json(force=True, silent=False)
except Exception as e:
logger.warning("[LINE] invalid webhook body: %s", e)
return "bad request", 400
if not isinstance(payload, dict):
return "bad request", 400
await self.handle_webhook_event(payload)
return "ok", 200
async def handle_webhook_event(self, payload: dict[str, Any]) -> None:
destination = str(payload.get("destination", "")).strip()
if destination:
self.destination = destination
events = payload.get("events")
if not isinstance(events, list):
return
for event in events:
if not isinstance(event, dict):
continue
event_id = str(event.get("webhookEventId", ""))
if event_id and self._is_duplicate_event(event_id):
logger.debug("[LINE] duplicate event skipped: %s", event_id)
continue
abm = await self.convert_message(event)
if abm is None:
continue
await self.handle_msg(abm)
async def convert_message(self, event: dict[str, Any]) -> AstrBotMessage | None:
if str(event.get("type", "")) != "message":
return None
if str(event.get("mode", "active")) == "standby":
return None
source = event.get("source", {})
if not isinstance(source, dict):
return None
message = event.get("message", {})
if not isinstance(message, dict):
return None
source_type = str(source.get("type", ""))
user_id = str(source.get("userId", "")).strip()
group_id = str(source.get("groupId", "")).strip()
room_id = str(source.get("roomId", "")).strip()
abm = AstrBotMessage()
abm.self_id = self.destination or self.meta().id
abm.message = []
abm.raw_message = event
abm.message_id = str(
message.get("id")
or event.get("webhookEventId")
or event.get("deliveryContext", {}).get("deliveryId", "")
or uuid.uuid4().hex
)
event_timestamp = event.get("timestamp")
if isinstance(event_timestamp, int):
abm.timestamp = (
event_timestamp // 1000
if event_timestamp > 1_000_000_000_000
else event_timestamp
)
else:
abm.timestamp = int(time.time())
if source_type in {"group", "room"}:
abm.type = MessageType.GROUP_MESSAGE
container_id = group_id or room_id
abm.group = Group(group_id=container_id, group_name=container_id)
abm.session_id = container_id
sender_id = user_id or container_id
elif source_type == "user":
abm.type = MessageType.FRIEND_MESSAGE
abm.session_id = user_id
sender_id = user_id
else:
abm.type = MessageType.OTHER_MESSAGE
abm.session_id = user_id or group_id or room_id or "unknown"
sender_id = abm.session_id
abm.sender = MessageMember(user_id=sender_id, nickname=sender_id[:8])
components = await self._parse_line_message_components(message)
if not components:
return None
abm.message = components
abm.message_str = self._build_message_str(components)
return abm
async def _parse_line_message_components(
self,
message: dict[str, Any],
) -> list:
msg_type = str(message.get("type", ""))
message_id = str(message.get("id", "")).strip()
if msg_type == "text":
text = str(message.get("text", ""))
mention = message.get("mention")
if isinstance(mention, dict):
return self._parse_text_with_mentions(text, mention)
return [Plain(text=text)] if text else []
if msg_type == "image":
image_component = await self._build_image_component(message_id, message)
return [image_component] if image_component else [Plain(text="[image]")]
if msg_type == "video":
video_component = await self._build_video_component(message_id, message)
return [video_component] if video_component else [Plain(text="[video]")]
if msg_type == "audio":
audio_component = await self._build_audio_component(message_id, message)
return [audio_component] if audio_component else [Plain(text="[audio]")]
if msg_type == "file":
file_component = await self._build_file_component(message_id, message)
return [file_component] if file_component else [Plain(text="[file]")]
if msg_type == "sticker":
return [Plain(text="[sticker]")]
return [Plain(text=f"[{msg_type}]")]
def _parse_text_with_mentions(self, text: str, mention_obj: dict[str, Any]) -> list:
mentions = mention_obj.get("mentionees", [])
if not isinstance(mentions, list) or not mentions:
return [Plain(text=text)] if text else []
normalized = []
for item in mentions:
if not isinstance(item, dict):
continue
start = item.get("index")
length = item.get("length")
if not isinstance(start, int) or not isinstance(length, int):
continue
normalized.append((start, length, item))
normalized.sort(key=lambda x: x[0])
ret = []
cursor = 0
for start, length, item in normalized:
if start > cursor:
part = text[cursor:start]
if part:
ret.append(Plain(text=part))
label = text[start : start + length] or "@user"
mention_type = str(item.get("type", ""))
if mention_type == "user":
target_id = str(item.get("userId", "")).strip()
ret.append(At(qq=target_id, name=label.lstrip("@")))
else:
ret.append(Plain(text=label))
cursor = max(cursor, start + length)
if cursor < len(text):
tail = text[cursor:]
if tail:
ret.append(Plain(text=tail))
return ret
async def _build_image_component(
self,
message_id: str,
message: dict[str, Any],
) -> Image | None:
external_url = self._get_external_content_url(message)
if external_url:
return Image.fromURL(external_url)
content = await self.line_api.get_message_content(message_id)
if not content:
return None
content_bytes, _, _ = content
return Image.fromBytes(content_bytes)
async def _build_video_component(
self,
message_id: str,
message: dict[str, Any],
) -> Video | None:
external_url = self._get_external_content_url(message)
if external_url:
return Video.fromURL(external_url)
content = await self.line_api.get_message_content(message_id)
if not content:
return None
content_bytes, content_type, _ = content
suffix = self._guess_suffix(content_type, ".mp4")
file_path = self._store_temp_content("video", message_id, content_bytes, suffix)
return Video(file=file_path, path=file_path)
async def _build_audio_component(
self,
message_id: str,
message: dict[str, Any],
) -> Record | None:
external_url = self._get_external_content_url(message)
if external_url:
return Record.fromURL(external_url)
content = await self.line_api.get_message_content(message_id)
if not content:
return None
content_bytes, content_type, _ = content
suffix = self._guess_suffix(content_type, ".m4a")
file_path = self._store_temp_content("audio", message_id, content_bytes, suffix)
return Record(file=file_path, url=file_path)
async def _build_file_component(
self,
message_id: str,
message: dict[str, Any],
) -> File | None:
content = await self.line_api.get_message_content(message_id)
if not content:
return None
content_bytes, content_type, filename = content
default_name = str(message.get("fileName", "")).strip() or f"{message_id}.bin"
suffix = Path(default_name).suffix or self._guess_suffix(content_type, ".bin")
final_name = filename or default_name
file_path = self._store_temp_content(
"file",
message_id,
content_bytes,
suffix,
original_name=final_name,
)
return File(name=final_name, file=file_path, url=file_path)
@staticmethod
def _get_external_content_url(message: dict[str, Any]) -> str:
provider = message.get("contentProvider")
if not isinstance(provider, dict):
return ""
if str(provider.get("type", "")) != "external":
return ""
return str(provider.get("originalContentUrl", "")).strip()
@staticmethod
def _guess_suffix(content_type: str | None, fallback: str) -> str:
if not content_type:
return fallback
base_type = content_type.split(";", 1)[0].strip().lower()
guessed = mimetypes.guess_extension(base_type)
if guessed:
return guessed
return fallback
@staticmethod
def _store_temp_content(
content_type: str,
message_id: str,
content: bytes,
suffix: str,
original_name: str = "",
) -> str:
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
name_prefix = f"line_{content_type}"
if original_name:
safe_stem = Path(original_name).stem.strip()
safe_stem = "".join(
ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in safe_stem
)
safe_stem = safe_stem.strip("._")
if safe_stem:
name_prefix = safe_stem[:64]
file_path = temp_dir / f"{name_prefix}_{message_id}_{uuid.uuid4().hex[:6]}"
file_path = file_path.with_suffix(suffix)
file_path.write_bytes(content)
return str(file_path.resolve())
@staticmethod
def _build_message_str(components: list) -> str:
parts: list[str] = []
for comp in components:
if isinstance(comp, Plain):
parts.append(comp.text)
elif isinstance(comp, At):
parts.append(f"@{comp.name or comp.qq}")
elif isinstance(comp, Image):
parts.append("[image]")
elif isinstance(comp, Video):
parts.append("[video]")
elif isinstance(comp, Record):
parts.append("[audio]")
elif isinstance(comp, File):
parts.append(str(comp.name or "[file]"))
else:
parts.append(f"[{comp.type}]")
return " ".join(i for i in parts if i).strip()
def _clean_expired_events(self) -> None:
current = time.time()
expired = [
event_id
for event_id, ts in self._event_id_timestamps.items()
if current - ts > 1800
]
for event_id in expired:
del self._event_id_timestamps[event_id]
def _is_duplicate_event(self, event_id: str) -> bool:
self._clean_expired_events()
if event_id in self._event_id_timestamps:
return True
self._event_id_timestamps[event_id] = time.time()
return False
async def handle_msg(self, abm: AstrBotMessage) -> None:
event = LineMessageEvent(
message_str=abm.message_str,
message_obj=abm,
platform_meta=self.meta(),
session_id=abm.session_id,
line_api=self.line_api,
)
self._event_queue.put_nowait(event)
@@ -0,0 +1,203 @@
import asyncio
import base64
import hmac
import json
from hashlib import sha256
from typing import Any
from urllib.parse import unquote
import aiohttp
from astrbot.api import logger
class LineAPIClient:
def __init__(
self,
*,
channel_access_token: str,
channel_secret: str,
timeout_seconds: int = 30,
) -> None:
self.channel_access_token = channel_access_token.strip()
self.channel_secret = channel_secret.strip()
self.timeout = aiohttp.ClientTimeout(total=timeout_seconds)
self._session: aiohttp.ClientSession | None = None
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(timeout=self.timeout)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
def verify_signature(self, raw_body: bytes, signature: str | None) -> bool:
if not signature:
return False
digest = hmac.new(
self.channel_secret.encode("utf-8"),
raw_body,
sha256,
).digest()
expected = base64.b64encode(digest).decode("utf-8")
return hmac.compare_digest(expected, signature.strip())
@property
def _auth_headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self.channel_access_token}"}
async def reply_message(
self,
reply_token: str,
messages: list[dict[str, Any]],
*,
notification_disabled: bool = False,
) -> bool:
payload = {
"replyToken": reply_token,
"messages": messages[:5],
"notificationDisabled": notification_disabled,
}
return await self._post_json(
"https://api.line.me/v2/bot/message/reply",
payload=payload,
op_name="reply",
)
async def push_message(
self,
to: str,
messages: list[dict[str, Any]],
*,
notification_disabled: bool = False,
) -> bool:
payload = {
"to": to,
"messages": messages[:5],
"notificationDisabled": notification_disabled,
}
return await self._post_json(
"https://api.line.me/v2/bot/message/push",
payload=payload,
op_name="push",
)
async def _post_json(
self,
url: str,
*,
payload: dict[str, Any],
op_name: str,
) -> bool:
session = await self._get_session()
headers = {
**self._auth_headers,
"Content-Type": "application/json",
}
try:
async with session.post(url, json=payload, headers=headers) as resp:
if resp.status < 400:
return True
body = await resp.text()
logger.error(
"[LINE] %s message failed: status=%s body=%s",
op_name,
resp.status,
body,
)
return False
except Exception as e:
logger.error("[LINE] %s message request failed: %s", op_name, e)
return False
async def get_message_content(
self,
message_id: str,
) -> tuple[bytes, str | None, str | None] | None:
session = await self._get_session()
url = f"https://api-data.line.me/v2/bot/message/{message_id}/content"
headers = self._auth_headers
async with session.get(url, headers=headers) as resp:
if resp.status == 202:
if not await self._wait_for_transcoding(message_id):
return None
async with session.get(url, headers=headers) as retry_resp:
if retry_resp.status != 200:
body = await retry_resp.text()
logger.warning(
"[LINE] get content retry failed: message_id=%s status=%s body=%s",
message_id,
retry_resp.status,
body,
)
return None
return await self._read_content_response(retry_resp)
if resp.status != 200:
body = await resp.text()
logger.warning(
"[LINE] get content failed: message_id=%s status=%s body=%s",
message_id,
resp.status,
body,
)
return None
return await self._read_content_response(resp)
async def _read_content_response(
self,
resp: aiohttp.ClientResponse,
) -> tuple[bytes, str | None, str | None]:
content = await resp.read()
content_type = resp.headers.get("Content-Type")
disposition = resp.headers.get("Content-Disposition")
filename = self._extract_filename_from_disposition(disposition)
return content, content_type, filename
def _extract_filename_from_disposition(self, disposition: str | None) -> str | None:
if not disposition:
return None
for part in disposition.split(";"):
token = part.strip()
if token.startswith("filename*="):
val = token.split("=", 1)[1].strip().strip('"')
if val.lower().startswith("utf-8''"):
val = val[7:]
return unquote(val)
if token.startswith("filename="):
return token.split("=", 1)[1].strip().strip('"')
return None
async def _wait_for_transcoding(
self,
message_id: str,
*,
max_attempts: int = 10,
interval_seconds: float = 1.0,
) -> bool:
session = await self._get_session()
url = (
f"https://api-data.line.me/v2/bot/message/{message_id}/content/transcoding"
)
headers = self._auth_headers
for _ in range(max_attempts):
try:
async with session.get(url, headers=headers) as resp:
if resp.status != 200:
await asyncio.sleep(interval_seconds)
continue
body = await resp.text()
data = json.loads(body)
status = str(data.get("status", "")).lower()
if status == "succeeded":
return True
if status == "failed":
return False
except Exception:
pass
await asyncio.sleep(interval_seconds)
return False
@@ -0,0 +1,285 @@
import asyncio
import os
import re
import uuid
from collections.abc import AsyncGenerator
from pathlib import Path
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import (
At,
BaseMessageComponent,
File,
Image,
Plain,
Record,
Video,
)
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.media_utils import get_media_duration
from .line_api import LineAPIClient
class LineMessageEvent(AstrMessageEvent):
def __init__(
self,
message_str,
message_obj,
platform_meta,
session_id,
line_api: LineAPIClient,
) -> None:
super().__init__(message_str, message_obj, platform_meta, session_id)
self.line_api = line_api
@staticmethod
async def _component_to_message_object(
segment: BaseMessageComponent,
) -> dict | None:
if isinstance(segment, Plain):
text = segment.text.strip()
if not text:
return None
return {"type": "text", "text": text[:5000]}
if isinstance(segment, At):
name = str(segment.name or segment.qq or "").strip()
if not name:
return None
return {"type": "text", "text": f"@{name}"[:5000]}
if isinstance(segment, Image):
image_url = await LineMessageEvent._resolve_image_url(segment)
if not image_url:
return None
return {
"type": "image",
"originalContentUrl": image_url,
"previewImageUrl": image_url,
}
if isinstance(segment, Record):
audio_url = await LineMessageEvent._resolve_record_url(segment)
if not audio_url:
return None
duration = await LineMessageEvent._resolve_record_duration(segment)
return {
"type": "audio",
"originalContentUrl": audio_url,
"duration": duration,
}
if isinstance(segment, Video):
video_url = await LineMessageEvent._resolve_video_url(segment)
if not video_url:
return None
preview_url = await LineMessageEvent._resolve_video_preview_url(segment)
if not preview_url:
return None
return {
"type": "video",
"originalContentUrl": video_url,
"previewImageUrl": preview_url,
}
if isinstance(segment, File):
file_url = await LineMessageEvent._resolve_file_url(segment)
if not file_url:
return None
file_name = str(segment.name or "").strip() or "file.bin"
file_size = await LineMessageEvent._resolve_file_size(segment)
if file_size <= 0:
return None
return {
"type": "file",
"fileName": file_name,
"fileSize": file_size,
"originalContentUrl": file_url,
}
return None
@staticmethod
async def _resolve_image_url(segment: Image) -> str:
candidate = (segment.url or segment.file or "").strip()
if candidate.startswith("http://") or candidate.startswith("https://"):
return candidate
try:
return await segment.register_to_file_service()
except Exception as e:
logger.debug("[LINE] resolve image url failed: %s", e)
return ""
@staticmethod
async def _resolve_record_url(segment: Record) -> str:
candidate = (segment.url or segment.file or "").strip()
if candidate.startswith("http://") or candidate.startswith("https://"):
return candidate
try:
return await segment.register_to_file_service()
except Exception as e:
logger.debug("[LINE] resolve record url failed: %s", e)
return ""
@staticmethod
async def _resolve_record_duration(segment: Record) -> int:
try:
file_path = await segment.convert_to_file_path()
duration_ms = await get_media_duration(file_path)
if isinstance(duration_ms, int) and duration_ms > 0:
return duration_ms
except Exception as e:
logger.debug("[LINE] resolve record duration failed: %s", e)
return 1000
@staticmethod
async def _resolve_video_url(segment: Video) -> str:
candidate = (segment.file or "").strip()
if candidate.startswith("http://") or candidate.startswith("https://"):
return candidate
try:
return await segment.register_to_file_service()
except Exception as e:
logger.debug("[LINE] resolve video url failed: %s", e)
return ""
@staticmethod
async def _resolve_video_preview_url(segment: Video) -> str:
cover_candidate = (segment.cover or "").strip()
if cover_candidate.startswith("http://") or cover_candidate.startswith(
"https://"
):
return cover_candidate
if cover_candidate:
try:
cover_seg = Image(file=cover_candidate)
return await cover_seg.register_to_file_service()
except Exception as e:
logger.debug("[LINE] resolve video cover failed: %s", e)
try:
video_path = await segment.convert_to_file_path()
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
thumb_path = temp_dir / f"line_video_preview_{uuid.uuid4().hex}.jpg"
process = await asyncio.create_subprocess_exec(
"ffmpeg",
"-y",
"-ss",
"00:00:01",
"-i",
video_path,
"-frames:v",
"1",
str(thumb_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await process.communicate()
if process.returncode != 0 or not thumb_path.exists():
return ""
cover_seg = Image.fromFileSystem(str(thumb_path))
return await cover_seg.register_to_file_service()
except Exception as e:
logger.debug("[LINE] generate video preview failed: %s", e)
return ""
@staticmethod
async def _resolve_file_url(segment: File) -> str:
if segment.url and segment.url.startswith(("http://", "https://")):
return segment.url
try:
return await segment.register_to_file_service()
except Exception as e:
logger.debug("[LINE] resolve file url failed: %s", e)
return ""
@staticmethod
async def _resolve_file_size(segment: File) -> int:
try:
file_path = await segment.get_file(allow_return_url=False)
if file_path and os.path.exists(file_path):
return int(os.path.getsize(file_path))
except Exception as e:
logger.debug("[LINE] resolve file size failed: %s", e)
return 0
@classmethod
async def build_line_messages(cls, message_chain: MessageChain) -> list[dict]:
messages: list[dict] = []
for segment in message_chain.chain:
obj = await cls._component_to_message_object(segment)
if obj:
messages.append(obj)
if not messages:
return []
if len(messages) > 5:
logger.warning(
"[LINE] message count exceeds 5, extra segments will be dropped."
)
messages = messages[:5]
return messages
async def send(self, message: MessageChain) -> None:
messages = await self.build_line_messages(message)
if not messages:
return
raw = self.message_obj.raw_message
reply_token = ""
if isinstance(raw, dict):
reply_token = str(raw.get("replyToken") or "")
sent = False
if reply_token:
sent = await self.line_api.reply_message(reply_token, messages)
if not sent:
target_id = self.get_group_id() or self.get_sender_id()
if target_id:
await self.line_api.push_message(target_id, messages)
await super().send(message)
async def send_streaming(
self,
generator: AsyncGenerator,
use_fallback: bool = False,
):
if not use_fallback:
buffer = None
async for chain in generator:
if not buffer:
buffer = chain
else:
buffer.chain.extend(chain.chain)
if not buffer:
return None
buffer.squash_plain()
await self.send(buffer)
return await super().send_streaming(generator, use_fallback)
buffer = ""
pattern = re.compile(r"[^。?!~…]+[。?!~…]+")
async for chain in generator:
if isinstance(chain, MessageChain):
for comp in chain.chain:
if isinstance(comp, Plain):
buffer += comp.text
if any(p in buffer for p in "。?!~…"):
buffer = await self.process_buffer(buffer, pattern)
else:
await self.send(MessageChain(chain=[comp]))
await asyncio.sleep(1.5)
if buffer.strip():
await self.send(MessageChain([Plain(buffer)]))
return await super().send_streaming(generator, use_fallback)
@@ -7,13 +7,14 @@ from typing import cast
import aiofiles
import botpy
import botpy.errors
import botpy.message
import botpy.types
import botpy.types.message
from botpy import Client
from botpy.http import Route
from botpy.types import message
from botpy.types.message import Media
from botpy.types.message import MarkdownPayload, Media
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -25,6 +26,8 @@ from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
class QQOfficialMessageEvent(AstrMessageEvent):
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
def __init__(
self,
message_str: str,
@@ -114,7 +117,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
return None
payload: dict = {
"content": plain_text,
# "content": plain_text,
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
"msg_type": 2,
"msg_id": self.message_obj.message_id,
}
@@ -145,9 +150,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
)
payload["media"] = media
payload["msg_type"] = 7
ret = await self.bot.api.post_group_message(
group_openid=source.group_openid,
**payload,
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
**retry_payload,
),
payload=payload,
plain_text=plain_text,
)
case botpy.message.C2CMessage():
@@ -168,30 +177,49 @@ class QQOfficialMessageEvent(AstrMessageEvent):
payload["media"] = media
payload["msg_type"] = 7
if stream:
ret = await self.post_c2c_message(
openid=source.author.user_openid,
**payload,
stream=stream,
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.post_c2c_message(
openid=source.author.user_openid,
**retry_payload,
stream=stream,
),
payload=payload,
plain_text=plain_text,
)
else:
ret = await self.post_c2c_message(
openid=source.author.user_openid,
**payload,
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.post_c2c_message(
openid=source.author.user_openid,
**retry_payload,
),
payload=payload,
plain_text=plain_text,
)
logger.debug(f"Message sent to C2C: {ret}")
case botpy.message.Message():
if image_path:
payload["file_image"] = image_path
ret = await self.bot.api.post_message(
channel_id=source.channel_id,
**payload,
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.bot.api.post_message(
channel_id=source.channel_id,
**retry_payload,
),
payload=payload,
plain_text=plain_text,
)
case botpy.message.DirectMessage():
if image_path:
payload["file_image"] = image_path
ret = await self.bot.api.post_dms(guild_id=source.guild_id, **payload)
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.bot.api.post_dms(
guild_id=source.guild_id,
**retry_payload,
),
payload=payload,
plain_text=plain_text,
)
case _:
pass
@@ -202,6 +230,32 @@ class QQOfficialMessageEvent(AstrMessageEvent):
return ret
async def _send_with_markdown_fallback(
self,
send_func,
payload: dict,
plain_text: str,
):
try:
return await send_func(payload)
except botpy.errors.ServerError as err:
if (
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
or not payload.get("markdown")
or not plain_text
):
raise
logger.warning(
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
)
fallback_payload = payload.copy()
fallback_payload["markdown"] = None
fallback_payload["content"] = plain_text
if fallback_payload.get("msg_type") == 2:
fallback_payload["msg_type"] = 0
return await send_func(fallback_payload)
async def upload_group_and_c2c_image(
self,
image_base64: str,
@@ -6,6 +6,7 @@ from typing import Any, cast
import telegramify_markdown
from telegram import ReactionTypeCustomEmoji, ReactionTypeEmoji
from telegram.constants import ChatAction
from telegram.error import BadRequest
from telegram.ext import ExtBot
from astrbot import logger
@@ -119,6 +120,65 @@ class TelegramPlatformEvent(AstrMessageEvent):
client, user_name, ChatAction.TYPING, message_thread_id
)
@classmethod
async def _send_voice_with_fallback(
cls,
client: ExtBot,
path: str,
payload: dict[str, Any],
*,
caption: str | None = None,
user_name: str = "",
message_thread_id: str | None = None,
use_media_action: bool = False,
) -> None:
"""Send a voice message, falling back to a document if the user's
privacy settings forbid voice messages (``BadRequest`` with
``Voice_messages_forbidden``).
When *use_media_action* is ``True`` the helper wraps the send calls
with ``_send_media_with_action`` (used by the streaming path).
"""
try:
if use_media_action:
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),
)
else:
await client.send_voice(voice=path, **cast(Any, payload))
except BadRequest as e:
# python-telegram-bot raises BadRequest for Voice_messages_forbidden;
# distinguish the voice-privacy case via the API error message.
if "Voice_messages_forbidden" not in e.message:
raise
logger.warning(
"User privacy settings prevent receiving voice messages, falling back to sending an audio file. "
"To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'."
)
if use_media_action:
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),
)
else:
await client.send_document(
document=path,
caption=caption,
**cast(Any, payload),
)
async def _ensure_typing(
self,
user_name: str,
@@ -211,7 +271,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
)
elif isinstance(i, Record):
path = await i.convert_to_file_path()
await client.send_voice(voice=path, **cast(Any, payload))
await cls._send_voice_with_fallback(
client,
path,
payload,
caption=i.text or None,
use_media_action=False,
)
async def send(self, message: MessageChain) -> None:
if self.get_message_type() == MessageType.GROUP_MESSAGE:
@@ -330,14 +396,14 @@ class TelegramPlatformEvent(AstrMessageEvent):
continue
elif isinstance(i, Record):
path = await i.convert_to_file_path()
await self._send_media_with_action(
await self._send_voice_with_fallback(
self.client,
ChatAction.UPLOAD_VOICE,
self.client.send_voice,
path,
payload,
caption=i.text or delta or None,
user_name=user_name,
message_thread_id=message_thread_id,
voice=path,
**cast(Any, payload),
use_media_action=True,
)
continue
else:
+6
View File
@@ -295,6 +295,12 @@ class ProviderManager:
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "groq_chat_completion":
from .sources.groq_source import ProviderGroq as ProviderGroq
case "xai_chat_completion":
from .sources.xai_source import ProviderXAI as ProviderXAI
case "aihubmix_chat_completion":
from .sources.oai_aihubmix_source import (
ProviderAIHubMix as ProviderAIHubMix,
)
case "anthropic_chat_completion":
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
@@ -0,0 +1,17 @@
from ..register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
@register_provider_adapter(
"aihubmix_chat_completion", "AIHubMix Chat Completion Provider Adapter"
)
class ProviderAIHubMix(ProviderOpenAIOfficial):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)
# Reference to: https://aihubmix.com/appstore
# Use this code can enjoy 10% off prices for AIHubMix API calls.
self.client._custom_headers["APP-Code"] = "KRLC5702" # type: ignore
+85 -3
View File
@@ -323,7 +323,8 @@ class ProviderOpenAIOfficial(Provider):
llm_response.reasoning_content = reasoning
_y = True
if delta.content:
completion_text = delta.content
# Don't strip streaming chunks to preserve spaces between words
completion_text = self._normalize_content(delta.content, strip=False)
llm_response.result_chain = MessageChain(
chain=[Comp.Plain(completion_text)],
)
@@ -371,6 +372,86 @@ class ProviderOpenAIOfficial(Provider):
output=completion_tokens,
)
@staticmethod
def _normalize_content(raw_content: Any, strip: bool = True) -> str:
"""Normalize content from various formats to plain string.
Some LLM providers return content as list[dict] format
like [{'type': 'text', 'text': '...'}] instead of
plain string. This method handles both formats.
Args:
raw_content: The raw content from LLM response, can be str, list, or other.
strip: Whether to strip whitespace from the result. Set to False for
streaming chunks to preserve spaces between words.
Returns:
Normalized plain text string.
"""
if isinstance(raw_content, list):
# Check if this looks like OpenAI content-part format
# Only process if at least one item has {'type': 'text', 'text': ...} structure
has_content_part = any(
isinstance(part, dict) and part.get("type") == "text"
for part in raw_content
)
if has_content_part:
text_parts = []
for part in raw_content:
if isinstance(part, dict) and part.get("type") == "text":
text_val = part.get("text", "")
# Coerce to str in case text is null or non-string
text_parts.append(str(text_val) if text_val is not None else "")
return "".join(text_parts)
# Not content-part format, return string representation
return str(raw_content)
if isinstance(raw_content, str):
content = raw_content.strip() if strip else raw_content
# Check if the string is a JSON-encoded list (e.g., "[{'type': 'text', ...}]")
# This can happen when streaming concatenates content that was originally list format
# Only check if it looks like a complete JSON array (requires strip for check)
check_content = raw_content.strip()
if (
check_content.startswith("[")
and check_content.endswith("]")
and len(check_content) < 8192
):
try:
# First try standard JSON parsing
parsed = json.loads(check_content)
except json.JSONDecodeError:
# If that fails, try parsing as Python literal (handles single quotes)
# This is safer than blind replace("'", '"') which corrupts apostrophes
try:
import ast
parsed = ast.literal_eval(check_content)
except (ValueError, SyntaxError):
parsed = None
if isinstance(parsed, list):
# Only convert if it matches OpenAI content-part schema
# i.e., at least one item has {'type': 'text', 'text': ...}
has_content_part = any(
isinstance(part, dict) and part.get("type") == "text"
for part in parsed
)
if has_content_part:
text_parts = []
for part in parsed:
if isinstance(part, dict) and part.get("type") == "text":
text_val = part.get("text", "")
# Coerce to str in case text is null or non-string
text_parts.append(
str(text_val) if text_val is not None else ""
)
if text_parts:
return "".join(text_parts)
return content
return str(raw_content)
async def _parse_openai_completion(
self, completion: ChatCompletion, tools: ToolSet | None
) -> LLMResponse:
@@ -383,8 +464,7 @@ class ProviderOpenAIOfficial(Provider):
# parse the text completion
if choice.message.content is not None:
# text completion
completion_text = str(choice.message.content).strip()
completion_text = self._normalize_content(choice.message.content)
# specially, some providers may set <think> tags around reasoning content in the completion text,
# we use regex to remove them, and store then in reasoning_content field
reasoning_pattern = re.compile(r"<think>(.*?)</think>", re.DOTALL)
@@ -394,6 +474,8 @@ class ProviderOpenAIOfficial(Provider):
[match.strip() for match in matches],
)
completion_text = reasoning_pattern.sub("", completion_text).strip()
# Also clean up orphan </think> tags that may leak from some models
completion_text = re.sub(r"</think>\s*$", "", completion_text).strip()
llm_response.result_chain = MessageChain().message(completion_text)
# parse the reasoning content if any
@@ -20,6 +20,7 @@ class PlatformAdapterType(enum.Flag):
WEIXIN_OFFICIAL_ACCOUNT = enum.auto()
SATORI = enum.auto()
MISSKEY = enum.auto()
LINE = enum.auto()
ALL = (
AIOCQHTTP
| QQOFFICIAL
@@ -34,6 +35,7 @@ class PlatformAdapterType(enum.Flag):
| WEIXIN_OFFICIAL_ACCOUNT
| SATORI
| MISSKEY
| LINE
)
@@ -51,6 +53,7 @@ ADAPTER_NAME_2_TYPE = {
"weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT,
"satori": PlatformAdapterType.SATORI,
"misskey": PlatformAdapterType.MISSKEY,
"line": PlatformAdapterType.LINE,
}
+2
View File
@@ -13,6 +13,7 @@ from .star_handler import (
register_on_llm_response,
register_on_llm_tool_respond,
register_on_platform_loaded,
register_on_plugin_error,
register_on_using_llm_tool,
register_on_waiting_llm_request,
register_permission_type,
@@ -32,6 +33,7 @@ __all__ = [
"register_on_decorating_result",
"register_on_llm_request",
"register_on_llm_response",
"register_on_plugin_error",
"register_on_platform_loaded",
"register_on_waiting_llm_request",
"register_permission_type",
@@ -339,6 +339,24 @@ def register_on_platform_loaded(**kwargs):
return decorator
def register_on_plugin_error(**kwargs):
"""当插件处理消息异常时触发。
Hook 参数:
event, plugin_name, handler_name, error, traceback_text
说明:
hook 中调用 `event.stop_event()` 可屏蔽默认报错回显
并由插件自行决定是否转发到其他会话
"""
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnPluginErrorEvent, **kwargs)
return awaitable
return decorator
def register_on_waiting_llm_request(**kwargs):
"""当等待调用 LLM 时的通知事件(在获取锁之前)
+9
View File
@@ -97,6 +97,14 @@ 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.OnPluginErrorEvent],
only_activated=True,
plugins_name: list[str] | None = None,
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
@overload
def get_handlers_by_event_type(
self,
@@ -192,6 +200,7 @@ class EventType(enum.Enum):
OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具
OnLLMToolRespondEvent = enum.auto() # 调用函数工具后
OnAfterMessageSentEvent = enum.auto() # 发送消息后
OnPluginErrorEvent = enum.auto() # 插件处理消息异常时
H = TypeVar("H", bound=Callable[..., Any])
+44 -11
View File
@@ -62,6 +62,9 @@ class PluginManager:
self._pm_lock = asyncio.Lock()
"""StarManager操作互斥锁"""
self.failed_plugin_dict = {}
"""加载失败插件的信息,用于后续可能的热重载"""
self.failed_plugin_info = ""
if os.getenv("ASTRBOT_RELOAD", "0") == "1":
asyncio.create_task(self._watch_plugins_changes())
@@ -327,6 +330,28 @@ class PluginManager:
except KeyError:
logger.warning(f"模块 {module_name} 未载入")
async def reload_failed_plugin(self, dir_name):
"""
重新加载未注册加载失败的插件
Args:
dir_name (str): 要重载的特定插件名称
Returns:
tuple: 返回 load() 方法的结果包含 (success, error_message)
- 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, "插件不存在于失败列表中"
async def reload(self, specified_plugin_name=None):
"""重新加载插件
@@ -488,6 +513,16 @@ class PluginManager:
)
logger.info(metadata)
metadata.config = plugin_config
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (metadata.author or "unknown").lower().replace("/", "_")
plugin_id = f"{p_author}/{p_name}"
# 在实例化前注入类属性,保证插件 __init__ 可读取这些值
if metadata.star_cls_type:
setattr(metadata.star_cls_type, "name", p_name)
setattr(metadata.star_cls_type, "author", p_author)
setattr(metadata.star_cls_type, "plugin_id", plugin_id)
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
if plugin_config and metadata.star_cls_type:
@@ -505,17 +540,10 @@ class PluginManager:
context=self.context,
)
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (
(metadata.author or "unknown").lower().replace("/", "_")
)
setattr(metadata.star_cls, "name", p_name)
setattr(metadata.star_cls, "author", p_author)
setattr(
metadata.star_cls,
"plugin_id",
f"{p_author}/{p_name}",
)
if metadata.star_cls:
setattr(metadata.star_cls, "name", p_name)
setattr(metadata.star_cls, "author", p_author)
setattr(metadata.star_cls, "plugin_id", plugin_id)
else:
logger.info(f"插件 {metadata.name} 已被禁用。")
@@ -663,6 +691,11 @@ class PluginManager:
logger.error(f"| {line}")
logger.error("----------------------------------")
fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {e!s}\n"
self.failed_plugin_dict[root_dir_name] = {
"error": str(e),
"traceback": errors,
}
# 记录注册失败的插件名称,以便后续重载插件
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
+2 -2
View File
@@ -148,8 +148,8 @@ class AstrBotUpdator(RepoZipUpdator):
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
file_url = None
if os.environ.get("ASTRBOT_CLI"):
raise Exception("不支持更新CLI启动的AstrBot") # 避免版本管理混乱
if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"):
raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱
if latest:
latest_version = update_data[0]["tag_name"]
+17 -1
View File
@@ -1,3 +1,4 @@
import os
import uuid
from astrbot.core import astrbot_config, logger
@@ -20,6 +21,20 @@ def _get_dashboard_port() -> int:
return 6185
def _is_dashboard_ssl_enabled() -> bool:
env_ssl = os.environ.get("DASHBOARD_SSL_ENABLE") or os.environ.get(
"ASTRBOT_DASHBOARD_SSL_ENABLE"
)
if env_ssl is not None:
return env_ssl.strip().lower() in {"1", "true", "yes", "on"}
try:
return bool(astrbot_config.get("dashboard", {}).get("ssl", {}).get("enable"))
except Exception as e:
logger.error(f"获取 dashboard SSL 配置失败: {e!s}")
return False
def log_webhook_info(platform_name: str, webhook_uuid: str) -> None:
"""打印美观的 webhook 信息日志
@@ -38,12 +53,13 @@ def log_webhook_info(platform_name: str, webhook_uuid: str) -> None:
callback_base = callback_base.rstrip("/")
webhook_url = f"{callback_base}/api/platform/webhook/{webhook_uuid}"
scheme = "https" if _is_dashboard_ssl_enabled() else "http"
display_log = (
"\n====================\n"
f"🔗 机器人平台 {platform_name} 已启用统一 Webhook 模式\n"
f"📍 Webhook 回调地址: \n"
f"http://<your-ip>:{_get_dashboard_port()}/api/platform/webhook/{webhook_uuid}\n"
f"{scheme}://<your-ip>:{_get_dashboard_port()}/api/platform/webhook/{webhook_uuid}\n"
f"{webhook_url}\n"
"====================\n"
)
+34
View File
@@ -54,11 +54,13 @@ class PluginRoute(Route):
"/plugin/market_list": ("GET", self.get_online_plugins),
"/plugin/off": ("POST", self.off_plugin),
"/plugin/on": ("POST", self.on_plugin),
"/plugin/reload-failed": ("POST", self.reload_failed_plugins),
"/plugin/reload": ("POST", self.reload_plugins),
"/plugin/readme": ("GET", self.get_plugin_readme),
"/plugin/changelog": ("GET", self.get_plugin_changelog),
"/plugin/source/get": ("GET", self.get_custom_source),
"/plugin/source/save": ("POST", self.save_custom_source),
"/plugin/source/get-failed-plugins": ("GET", self.get_failed_plugins),
}
self.core_lifecycle = core_lifecycle
self.plugin_manager = plugin_manager
@@ -71,10 +73,38 @@ class PluginRoute(Route):
EventType.OnDecoratingResultEvent: "回复消息前",
EventType.OnCallingFuncToolEvent: "函数工具",
EventType.OnAfterMessageSentEvent: "发送消息后",
EventType.OnPluginErrorEvent: "插件报错时",
}
self._logo_cache = {}
async def reload_failed_plugins(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
try:
data = await request.get_json()
dir_name = data.get("dir_name") # 这里拿的是目录名,不是插件名
if not dir_name:
return Response().error("缺少插件目录名").__dict__
# 调用 star_manager.py 中的函数
# 注意:传入的是目录名
success, err = await self.plugin_manager.reload_failed_plugin(dir_name)
if success:
return Response().ok(None, f"插件 {dir_name} 重载成功。").__dict__
else:
return Response().error(f"重载失败: {err}").__dict__
except Exception as e:
logger.error(f"/api/plugin/reload-failed: {traceback.format_exc()}")
return Response().error(str(e)).__dict__
async def reload_plugins(self):
if DEMO_MODE:
return (
@@ -334,6 +364,10 @@ class PluginRoute(Route):
.__dict__
)
async def get_failed_plugins(self):
"""专门获取加载失败的插件列表(字典格式)"""
return Response().ok(self.plugin_manager.failed_plugin_dict).__dict__
async def get_plugin_handlers_info(self, handler_full_names: list[str]):
"""解析插件行为"""
handlers = []
+2 -1
View File
@@ -295,14 +295,15 @@ class StatRoute(Route):
if locale:
candidates.append(base_path / f"FIRST_NOTICE.{locale}.md")
if locale.lower().startswith("zh"):
candidates.append(base_path / "FIRST_NOTICE.md")
candidates.append(base_path / "FIRST_NOTICE.zh-CN.md")
elif locale.lower().startswith("en"):
candidates.append(base_path / "FIRST_NOTICE.en-US.md")
candidates.extend(
[
base_path / "FIRST_NOTICE.en-US.md",
base_path / "FIRST_NOTICE.md",
base_path / "FIRST_NOTICE.en-US.md",
],
)
+60 -9
View File
@@ -2,6 +2,7 @@ import asyncio
import logging
import os
import socket
from pathlib import Path
from typing import Protocol, cast
import jwt
@@ -36,6 +37,12 @@ class _AddrWithPort(Protocol):
APP: Quart
def _parse_env_bool(value: str | None, default: bool) -> bool:
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
class AstrBotDashboard:
def __init__(
self,
@@ -201,23 +208,33 @@ class AstrBotDashboard:
def run(self):
ip_addr = []
dashboard_config = self.core_lifecycle.astrbot_config.get("dashboard", {})
port = (
os.environ.get("DASHBOARD_PORT")
or os.environ.get("ASTRBOT_DASHBOARD_PORT")
or self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185)
or dashboard_config.get("port", 6185)
)
host = (
os.environ.get("DASHBOARD_HOST")
or os.environ.get("ASTRBOT_DASHBOARD_HOST")
or self.core_lifecycle.astrbot_config["dashboard"].get("host", "0.0.0.0")
or dashboard_config.get("host", "0.0.0.0")
)
enable = self.core_lifecycle.astrbot_config["dashboard"].get("enable", True)
enable = dashboard_config.get("enable", True)
ssl_config = dashboard_config.get("ssl", {})
if not isinstance(ssl_config, dict):
ssl_config = {}
ssl_enable = _parse_env_bool(
os.environ.get("DASHBOARD_SSL_ENABLE")
or os.environ.get("ASTRBOT_DASHBOARD_SSL_ENABLE"),
bool(ssl_config.get("enable", False)),
)
scheme = "https" if ssl_enable else "http"
if not enable:
logger.info("WebUI 已被禁用")
return None
logger.info(f"正在启动 WebUI, 监听地址: http://{host}:{port}")
logger.info(f"正在启动 WebUI, 监听地址: {scheme}://{host}:{port}")
if host == "0.0.0.0":
logger.info(
"提示: WebUI 将监听所有网络接口,请注意安全。(可在 data/cmd_config.json 中配置 dashboard.host 以修改 host",
@@ -245,9 +262,9 @@ class AstrBotDashboard:
raise Exception(f"端口 {port} 已被占用")
parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动,可访问\n\n"]
parts.append(f" ➜ 本地: http://localhost:{port}\n")
parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n")
for ip in ip_addr:
parts.append(f" ➜ 网络: http://{ip}:{port}\n")
parts.append(f" ➜ 网络: {scheme}://{ip}:{port}\n")
parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n")
display = "".join(parts)
@@ -261,11 +278,45 @@ class AstrBotDashboard:
# 配置 Hypercorn
config = HyperConfig()
config.bind = [f"{host}:{port}"]
if ssl_enable:
cert_file = (
os.environ.get("DASHBOARD_SSL_CERT")
or os.environ.get("ASTRBOT_DASHBOARD_SSL_CERT")
or ssl_config.get("cert_file", "")
)
key_file = (
os.environ.get("DASHBOARD_SSL_KEY")
or os.environ.get("ASTRBOT_DASHBOARD_SSL_KEY")
or ssl_config.get("key_file", "")
)
ca_certs = (
os.environ.get("DASHBOARD_SSL_CA_CERTS")
or os.environ.get("ASTRBOT_DASHBOARD_SSL_CA_CERTS")
or ssl_config.get("ca_certs", "")
)
cert_path = Path(cert_file).expanduser()
key_path = Path(key_file).expanduser()
if not cert_file or not key_file:
raise ValueError(
"dashboard.ssl.enable 为 true 时,必须配置 cert_file 和 key_file。",
)
if not cert_path.is_file():
raise ValueError(f"SSL 证书文件不存在: {cert_path}")
if not key_path.is_file():
raise ValueError(f"SSL 私钥文件不存在: {key_path}")
config.certfile = str(cert_path.resolve())
config.keyfile = str(key_path.resolve())
if ca_certs:
ca_path = Path(ca_certs).expanduser()
if not ca_path.is_file():
raise ValueError(f"SSL CA 证书文件不存在: {ca_path}")
config.ca_certs = str(ca_path.resolve())
# 根据配置决定是否禁用访问日志
disable_access_log = self.core_lifecycle.astrbot_config.get(
"dashboard", {}
).get("disable_access_log", True)
disable_access_log = dashboard_config.get("disable_access_log", True)
if disable_access_log:
config.accesslog = None
else:
+62
View File
@@ -0,0 +1,62 @@
## What's Changed
### 新增
- QQ 官方机器人平台支持主动推送消息,私聊场景支持接收文件 ([#5066](https://github.com/AstrBotDevs/AstrBot/issues/5066))
- 为 Telegram 平台适配器新增等待 AI 回复时自动展示 “正在输入”、“正在上传图片” 等状态的功能 ([#5037](https://github.com/AstrBotDevs/AstrBot/issues/5037))
- 为飞书适配器增加接收文件、读取引用消息的内容(包括引用的图片、视频、文件、文字等) ([#5018](https://github.com/AstrBotDevs/AstrBot/issues/5018))
- 新增自定义平台适配器 i18n 支持 ([#5045](https://github.com/AstrBotDevs/AstrBot/issues/5045))
- 新增临时文件处理能力,可在系统配置中限制 data/temp 目录的最大大小。 ([#5026](https://github.com/AstrBotDevs/AstrBot/issues/5026))
- 增加首次启动公告功能,支持多语言与 WebUI 集成
### 修复
- 修复 OpenRouter DeepSeek 场景下的 chunk 错误 ([#5069](https://github.com/AstrBotDevs/AstrBot/issues/5069))
- 修复备份时人格文件夹映射缺失问题 ([#5042](https://github.com/AstrBotDevs/AstrBot/issues/5042))
- 修复更新日志与官方文档弹窗双滚动条问题 ([#5060](https://github.com/AstrBotDevs/AstrBot/issues/5060))
- 修复 provider 额外参数弹窗 key 显示异常
- 修复连接失败时错误日志提示不准确的问题
- 修复提前返回时未等待 reset 协程导致的资源清理问题 ([#5033](https://github.com/AstrBotDevs/AstrBot/issues/5033))
- 提升打包版桌面端启动稳定性并优化插件依赖处理 ([#5031](https://github.com/AstrBotDevs/AstrBot/issues/5031))
- 为 Electron 与后端日志增加按大小轮转 ([#5029](https://github.com/AstrBotDevs/AstrBot/issues/5029))
- 加固冻结运行时(frozen app runtime)插件依赖加载流程 ([#5015](https://github.com/AstrBotDevs/AstrBot/issues/5015))
### 优化
- 完善合并消息、引用解析与图片回退,并支持配置化控制 ([#5054](https://github.com/AstrBotDevs/AstrBot/issues/5054))
- 配置页面支持通过侧边栏子项切换普通配置/系统配置,并补充相关路由修复
- 优化分段回复间隔时间初始化逻辑 ([#5068](https://github.com/AstrBotDevs/AstrBot/issues/5068))
### 文档与维护
- 同步并修正 README 文档内容与拼写 ([#5055](https://github.com/AstrBotDevs/AstrBot/issues/5055), [#5014](https://github.com/AstrBotDevs/AstrBot/issues/5014))
- 新增 AUR 安装方式说明 ([#4879](https://github.com/AstrBotDevs/AstrBot/issues/4879))
- 执行代码格式化(ruff
## What's Changed (EN)
### New Features
- Added proactive message push and private-chat file receiving support for the QQ official bot adapter ([#5066](https://github.com/AstrBotDevs/AstrBot/issues/5066))
- Added automatic "typing..." and "uploading image..." status display while waiting for AI response in the Telegram adapter ([#5037](https://github.com/AstrBotDevs/AstrBot/issues/5037))
- Added file receiving and quoted message content reading (including quoted images, videos, files, text, etc.) for the Feishu adapter ([#5018](https://github.com/AstrBotDevs/AstrBot/issues/5018))
- Added i18n support for custom platform adapters ([#5045](https://github.com/AstrBotDevs/AstrBot/issues/5045))
- Introduced temporary file handling and `TempDirCleaner` ([#5026](https://github.com/AstrBotDevs/AstrBot/issues/5026))
- Added a first-launch notice feature with multilingual content and WebUI integration
### Fixes
- Added sidebar child-tab switching for normal/system config and fixed related routing behavior on the config page
- Fixed chunk errors when using OpenRouter DeepSeek ([#5069](https://github.com/AstrBotDevs/AstrBot/issues/5069))
- Improved forwarded-quote parsing and image fallback with configurable controls ([#5054](https://github.com/AstrBotDevs/AstrBot/issues/5054))
- Fixed missing persona-folder mapping in backup exports ([#5042](https://github.com/AstrBotDevs/AstrBot/issues/5042))
- Fixed double scrollbar issue in changelog and official docs dialogs ([#5060](https://github.com/AstrBotDevs/AstrBot/issues/5060))
- Fixed key rendering issues in the provider extra-params dialog
- Improved error log wording for connection failures
- Fixed unawaited reset coroutine cleanup on early returns ([#5033](https://github.com/AstrBotDevs/AstrBot/issues/5033))
- Improved packaged desktop startup stability and plugin dependency handling ([#5031](https://github.com/AstrBotDevs/AstrBot/issues/5031))
- Added size-based log rotation for Electron and backend logs ([#5029](https://github.com/AstrBotDevs/AstrBot/issues/5029))
- Hardened plugin dependency loading in frozen app runtime ([#5015](https://github.com/AstrBotDevs/AstrBot/issues/5015))
### Improvements
- Optimized initialization logic for segmented-reply interval timing ([#5068](https://github.com/AstrBotDevs/AstrBot/issues/5068))
### Docs & Maintenance
- Synced and fixed README docs and typos ([#5055](https://github.com/AstrBotDevs/AstrBot/issues/5055), [#5014](https://github.com/AstrBotDevs/AstrBot/issues/5014))
- Added AUR installation instructions ([#4879](https://github.com/AstrBotDevs/AstrBot/issues/4879))
- Applied code formatting with ruff
+29
View File
@@ -0,0 +1,29 @@
## What's Changed
### 新增
- 新增 LINE 平台适配器与相关配置支持 ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085))
- 新增备用回退聊天模型列表,当主模型报错时自动切换到备用模型 ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109))
- 新增插件加载失败后的热重载支持,便于插件修复后快速恢复 ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043))
- WebUI 新增 SSL 配置选项并同步更新相关日志行为 ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117))
### 修复
- 修复 Dockerfile 中依赖导出流程,增加 `uv lock` 步骤并移除不必要的 `--frozen` 参数,提升构建稳定性 ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089))
- 修复首次启动公告 `FIRST_NOTICE.md` 的本地化路径解析问题,补充兼容路径处理 ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082))
### 优化
- 日志系统由 `colorlog` 切换为 `loguru`,增强日志输出与展示能力 ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115))
## What's Changed (EN)
### New Features
- Added LINE platform adapter support with related configuration options ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085))
- Added fallback chat model chain support in tool loop runner, with corresponding config and improved provider selection display ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109))
- Added hot reload support after plugin load failure for faster recovery during plugin development and maintenance ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043))
- Added SSL configuration options for WebUI and updated related logging behavior ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117))
### Fixes
- Fixed Dockerfile dependency export flow by adding a `uv lock` step and removing unnecessary `--frozen` flag to improve build stability ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089))
- Fixed locale path resolution for `FIRST_NOTICE.md` and added compatible fallback handling ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082))
### Improvements
- Replaced `colorlog` with `loguru` to improve logging capabilities and console display ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115))
+34
View File
@@ -0,0 +1,34 @@
## What's Changed
hotfix of 4.17.0
- 修复:当开启了 “启用文件日志” 后,无法启动 AstrBot,报错 `ValueError: Invalid unit value while parsing duration: 'files'`。这是由于日志轮转设置中保留配置错误导致的,已通过根据备份数量正确设置保留参数进行修复。
- fix: When "Enable file logging" is turned on, AstrBot fails to start with error `ValueError: Invalid unit value while parsing duration: 'files'`. This is due to an incorrect retention configuration in the log rotation setup, which has been fixed by properly setting the retention parameter based on backup count.
### 新增
- 新增 LINE 平台适配器与相关配置支持 ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085))
- 新增备用回退聊天模型列表,当主模型报错时自动切换到备用模型 ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109))
- 新增插件加载失败后的热重载支持,便于插件修复后快速恢复 ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043))
- WebUI 新增 SSL 配置选项并同步更新相关日志行为 ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117))
### 修复
- 修复 Dockerfile 中依赖导出流程,增加 `uv lock` 步骤并移除不必要的 `--frozen` 参数,提升构建稳定性 ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089))
- 修复首次启动公告 `FIRST_NOTICE.md` 的本地化路径解析问题,补充兼容路径处理 ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082))
### 优化
- 日志系统由 `colorlog` 切换为 `loguru`,增强日志输出与展示能力 ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115))
## What's Changed (EN)
### New Features
- Added LINE platform adapter support with related configuration options ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085))
- Added fallback chat model chain support in tool loop runner, with corresponding config and improved provider selection display ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109))
- Added hot reload support after plugin load failure for faster recovery during plugin development and maintenance ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043))
- Added SSL configuration options for WebUI and updated related logging behavior ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117))
### Fixes
- Fixed Dockerfile dependency export flow by adding a `uv lock` step and removing unnecessary `--frozen` flag to improve build stability ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089))
- Fixed locale path resolution for `FIRST_NOTICE.md` and added compatible fallback handling ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082))
### Improvements
- Replaced `colorlog` with `loguru` to improve logging capabilities and console display ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115))
+8
View File
@@ -0,0 +1,8 @@
## What's Changed
hotfix of 4.17.0
- 修复:MCP 服务器的 Tools 没有被正确添加到上下文中。
- 修复:Electron 桌面应用部署时,系统自带插件未被正确加载的问题。
- fix: Tools from MCP server were not properly added to context.
- fix: built-in plugins were not properly loaded in Electron desktop application deployment.
+27
View File
@@ -0,0 +1,27 @@
## What's Changed
### 修复
- ‼️ 修复 Python 3.14 环境下 `'Plain' object has no attribute 'text'` 报错问题 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154))。
- ‼️ 修复插件元数据处理流程:在实例化前注入必要属性,避免初始化阶段元数据缺失 ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155))。
- 修复桌面端后端构建中 AstrBot 内置插件运行时依赖未打包的问题 ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146))。
- 修复通过 AstrBot Launcher 启动时仍被检测并触发更新的问题。
### 优化
- Webchat 下,使用 `astrbot_execute_ipython` 工具如果返回了图片,会自动将图片发送到聊天中。
### 其他
- 执行 `ruff format` 代码格式整理。
## What's Changed (EN)
### Fixes
- ‼️ Fixed plugin metadata handling by injecting required attributes before instantiation to avoid missing metadata during initialization ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155)).
- ‼️ Fixed `'Plain' object has no attribute 'text'` error when using Python 3.14 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154)).
- Fixed missing runtime dependencies for built-in plugins in desktop backend builds ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146)).
- Fixed update checks being triggered when AstrBot is launched via AstrBot Launcher.
### Improvements
- In Webchat, when using the `astrbot_execute_ipython` tool, if an image is returned, it will automatically be sent to the chat.
### Others
- Applied `ruff format` code formatting.
+32
View File
@@ -0,0 +1,32 @@
## What's Changed
### 新增
- 新增 NVIDIA Provider 模板,便于快速接入 NVIDIA 模型服务 ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157))。
- 支持在 WebUI 搜索配置
### 修复
- 修复 CronJob 页面操作列按钮重叠问题,提升任务管理可用性 ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163))。
### 优化
- 优化 Python / Shell 本地执行工具的权限拒绝提示信息引导,提升排障可读性。
- Provider 来源面板样式升级,新增菜单交互并完善移动端适配。
- PersonaForm 组件增强响应式布局与样式细节,改进不同屏幕下的编辑体验 ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162))。
- 配置页面新增未保存变更提示,减少误操作导致的配置丢失。
- 配置相关组件新增搜索能力并同步更新界面交互,提升配置项定位效率 ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168))。
## What's Changed (EN)
### New Features
- Added an NVIDIA provider template for faster integration with NVIDIA model services ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157)).
- Added an announcement section to the Welcome page, with localized announcement title support.
- Added an FAQ link to the vertical sidebar and updated navigation for localization.
### Fixes
- Fixed overlapping action buttons in the CronJob page action column to improve task management usability ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163)).
- Improved permission-denied messages for local execution in Python and shell tools for better troubleshooting clarity.
### Improvements
- Enhanced the provider sources panel with a refined menu style and better mobile support.
- Improved PersonaForm with responsive layout and styling updates for better editing experience across screen sizes ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162)).
- Added an unsaved-changes notice on the configuration page to reduce accidental config loss.
- Added search functionality to configuration components and updated related UI interactions for faster settings discovery ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168)).
+37
View File
@@ -0,0 +1,37 @@
## What's Changed
### 新增
- 支持 QQ 官方机器人平台发送 Markdown 消息,提升富文本消息呈现能力 ([#5173](https://github.com/AstrBotDevs/AstrBot/issues/5173))。
- 新增在插件市场中集成随机插件推荐能力 ([#5190](https://github.com/AstrBotDevs/AstrBot/issues/5190))。
- 新增插件错误钩子(plugin error hook),支持自定义错误路由处理,便于插件统一异常控制 ([#5192](https://github.com/AstrBotDevs/AstrBot/issues/5192))。
### 修复
- 修复全部 LLM Provider 失败时重复显示错误信息的问题,减少冗余报错干扰 ([#5183](https://github.com/AstrBotDevs/AstrBot/issues/5183))。
- 修复从“选择配置文件”进入配置管理后直接关闭弹窗时,显示配置文件不正确的问题 ([#5174](https://github.com/AstrBotDevs/AstrBot/issues/5174))。
### 优化
- 重构 telegram `Voice_messages_forbidden` 回退逻辑,提取为共享辅助方法并引入类型化 `BadRequest` 异常,提升异常处理一致性 ([#5204](https://github.com/AstrBotDevs/AstrBot/issues/5204))。
### 其他
- 更新 README 相关文档内容。
- 执行 `ruff format` 代码格式整理。
## What's Changed (EN)
### New Features
- Added a plugin error hook for custom error routing, enabling unified exception handling in plugins ([#5192](https://github.com/AstrBotDevs/AstrBot/issues/5192)).
- Added Markdown message sending support for `qqofficial` to improve rich-text delivery ([#5173](https://github.com/AstrBotDevs/AstrBot/issues/5173)).
- Added the `MarketPluginCard` component and integrated random plugin recommendations in the extension marketplace ([#5190](https://github.com/AstrBotDevs/AstrBot/issues/5190)).
- Added support for the `aihubmix` provider.
- Added LINE support notes to multilingual README files.
### Fixes
- Fixed duplicate error messages when all LLM providers fail, reducing noisy error output ([#5183](https://github.com/AstrBotDevs/AstrBot/issues/5183)).
- Fixed incorrect displayed profile after opening configuration management from profile selection and closing the dialog directly ([#5174](https://github.com/AstrBotDevs/AstrBot/issues/5174)).
### Improvements
- Refactored `Voice_messages_forbidden` fallback logic into a shared helper and introduced a typed `BadRequest` exception for more consistent error handling ([#5204](https://github.com/AstrBotDevs/AstrBot/issues/5204)).
### Others
- Updated README documentation.
- Applied `ruff format` code formatting.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -2,18 +2,22 @@
<div :class="$vuetify.display.mobile ? '' : 'd-flex'">
<v-tabs v-model="tab" :direction="$vuetify.display.mobile ? 'horizontal' : 'vertical'"
:align-tabs="$vuetify.display.mobile ? 'left' : 'start'" color="deep-purple-accent-4" class="config-tabs">
<v-tab v-for="(val, key, index) in metadata" :key="index" :value="index"
<v-tab v-for="section in visibleSections" :key="section.key" :value="section.key"
style="font-weight: 1000; font-size: 15px">
{{ tm(metadata[key]['name']) }}
{{ tm(section.value['name']) }}
</v-tab>
</v-tabs>
<v-tabs-window v-model="tab" class="config-tabs-window" :style="readonly ? 'pointer-events: none; opacity: 0.6;' : ''">
<v-tabs-window-item v-for="(val, key, index) in metadata" v-show="index == tab" :key="index">
<v-tabs-window-item v-for="section in visibleSections" :key="section.key" :value="section.key">
<v-container fluid>
<div v-for="(val2, key2, index2) in metadata[key]['metadata']" :key="key2">
<div v-for="(val2, key2, index2) in section.value['metadata']" :key="key2">
<!-- Support both traditional and JSON selector metadata -->
<AstrBotConfigV4 :metadata="{ [key2]: metadata[key]['metadata'][key2] }" :iterable="config_data"
:metadataKey="key2">
<AstrBotConfigV4
:metadata="{ [key2]: section.value['metadata'][key2] }"
:iterable="config_data"
:metadataKey="key2"
:search-keyword="searchKeyword"
>
</AstrBotConfigV4>
</div>
</v-container>
@@ -31,6 +35,11 @@
</v-tabs-window>
</div>
<v-container v-if="visibleSections.length === 0" fluid class="px-0">
<v-alert type="info" variant="tonal">
{{ tm('search.noResult') }}
</v-alert>
</v-container>
</template>
<script>
@@ -56,6 +65,10 @@ export default {
readonly: {
type: Boolean,
default: false
},
searchKeyword: {
type: String,
default: ''
}
},
setup() {
@@ -76,11 +89,63 @@ export default {
},
data() {
return {
tab: 0, //
tab: null, // key
}
},
computed: {
normalizedSearchKeyword() {
return String(this.searchKeyword || '').trim().toLowerCase();
},
visibleSections() {
if (!this.metadata || typeof this.metadata !== 'object') {
return [];
}
const allSections = Object.entries(this.metadata).map(([key, value]) => ({ key, value }));
if (!this.normalizedSearchKeyword) {
return allSections;
}
return allSections.filter((section) => this.sectionHasSearchMatch(section.value));
}
},
watch: {
visibleSections(newSections) {
const sectionKeys = newSections.map((section) => section.key);
if (!sectionKeys.includes(this.tab)) {
this.tab = sectionKeys[0] ?? null;
}
}
},
mounted() {
const sectionKeys = this.visibleSections.map((section) => section.key);
this.tab = sectionKeys[0] ?? null;
},
methods: {
//
sectionHasSearchMatch(section) {
const keyword = this.normalizedSearchKeyword;
if (!keyword) {
return true;
}
const sectionMetadata = section?.metadata || {};
return Object.values(sectionMetadata).some((metaItem) => this.metaObjectHasSearchMatch(metaItem, keyword));
},
metaObjectHasSearchMatch(metaObject, keyword) {
if (!metaObject || typeof metaObject !== 'object') {
return false;
}
const target = [
this.tm(metaObject.description || ''),
this.tm(metaObject.hint || ''),
...Object.entries(metaObject.items || {}).flatMap(([itemKey, itemMeta]) => ([
itemKey,
this.tm(itemMeta?.description || ''),
this.tm(itemMeta?.hint || '')
]))
]
.join(' ')
.toLowerCase();
return target.includes(keyword);
}
}
}
</script>
@@ -112,4 +177,4 @@ export default {
margin-top: 16px;
}
}
</style>
</style>
@@ -0,0 +1,277 @@
<script setup>
import { useModuleI18n } from "@/i18n/composables";
const { tm } = useModuleI18n("features/extension");
defineProps({
plugin: {
type: Object,
required: true,
},
defaultPluginIcon: {
type: String,
required: true,
},
showPluginFullName: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["install"]);
const handleInstall = (plugin) => {
emit("install", plugin);
};
</script>
<template>
<v-card
class="rounded-lg d-flex flex-column plugin-card"
elevation="0"
style="height: 12rem; position: relative"
>
<v-chip
v-if="plugin?.pinned"
color="warning"
size="x-small"
label
style="
position: absolute;
right: 8px;
top: 8px;
z-index: 10;
height: 20px;
font-weight: bold;
"
>
{{ tm("market.recommended") }}
</v-chip>
<v-card-text
style="
padding: 12px;
padding-bottom: 8px;
display: flex;
gap: 12px;
width: 100%;
flex: 1;
overflow: hidden;
"
>
<div style="flex-shrink: 0">
<img
:src="plugin?.logo || defaultPluginIcon"
:alt="plugin.name"
style="
height: 75px;
width: 75px;
border-radius: 8px;
object-fit: cover;
"
/>
</div>
<div
style="
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
"
>
<div
class="font-weight-bold"
style="
margin-bottom: 4px;
line-height: 1.3;
font-size: 1.2rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
"
>
<span style="overflow: hidden; text-overflow: ellipsis">
{{
plugin.display_name?.length
? plugin.display_name
: showPluginFullName
? plugin.name
: plugin.trimmedName
}}
</span>
</div>
<div class="d-flex align-center" style="gap: 4px; margin-bottom: 6px">
<v-icon
icon="mdi-account"
size="x-small"
style="color: rgba(var(--v-theme-on-surface), 0.5)"
></v-icon>
<a
v-if="plugin?.social_link"
:href="plugin.social_link"
target="_blank"
class="text-subtitle-2 font-weight-medium"
style="
text-decoration: none;
color: rgb(var(--v-theme-primary));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
"
>
{{ plugin.author }}
</a>
<span
v-else
class="text-subtitle-2 font-weight-medium"
style="
color: rgb(var(--v-theme-primary));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
"
>
{{ plugin.author }}
</span>
<div
class="d-flex align-center text-subtitle-2 ml-2"
style="color: rgba(var(--v-theme-on-surface), 0.7)"
>
<v-icon
icon="mdi-source-branch"
size="x-small"
style="margin-right: 2px"
></v-icon>
<span>{{ plugin.version }}</span>
</div>
</div>
<div class="text-caption plugin-description">
{{ plugin.desc }}
</div>
<div class="d-flex align-center" style="gap: 8px; margin-top: auto">
<div
v-if="plugin.stars !== undefined"
class="d-flex align-center text-subtitle-2"
style="color: rgba(var(--v-theme-on-surface), 0.7)"
>
<v-icon
icon="mdi-star"
size="x-small"
style="margin-right: 2px"
></v-icon>
<span>{{ plugin.stars }}</span>
</div>
<div
v-if="plugin.updated_at"
class="d-flex align-center text-subtitle-2"
style="color: rgba(var(--v-theme-on-surface), 0.7)"
>
<v-icon
icon="mdi-clock-outline"
size="x-small"
style="margin-right: 2px"
></v-icon>
<span>{{ new Date(plugin.updated_at).toLocaleString() }}</span>
</div>
</div>
</div>
</v-card-text>
<v-card-actions style="gap: 6px; padding: 8px 12px; padding-top: 0">
<v-chip
v-for="tag in plugin.tags?.slice(0, 2)"
:key="tag"
:color="tag === 'danger' ? 'error' : 'primary'"
label
size="x-small"
style="height: 20px"
>
{{ tag === "danger" ? tm("tags.danger") : tag }}
</v-chip>
<v-menu v-if="plugin.tags && plugin.tags.length > 2" open-on-hover offset-y>
<template v-slot:activator="{ props: menuProps }">
<v-chip
v-bind="menuProps"
color="grey"
label
size="x-small"
style="height: 20px; cursor: pointer"
>
+{{ plugin.tags.length - 2 }}
</v-chip>
</template>
<v-list density="compact">
<v-list-item v-for="tag in plugin.tags.slice(2)" :key="tag">
<v-chip :color="tag === 'danger' ? 'error' : 'primary'" label size="small">
{{ tag === "danger" ? tm("tags.danger") : tag }}
</v-chip>
</v-list-item>
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-btn
v-if="plugin?.repo"
color="secondary"
size="x-small"
variant="tonal"
:href="plugin.repo"
target="_blank"
style="height: 24px"
>
<v-icon icon="mdi-github" start size="x-small"></v-icon>
{{ tm("buttons.viewRepo") }}
</v-btn>
<v-btn
v-if="!plugin?.installed"
color="primary"
size="x-small"
@click="handleInstall(plugin)"
variant="flat"
style="height: 24px"
>
{{ tm("buttons.install") }}
</v-btn>
<v-chip v-else color="success" size="x-small" label style="height: 20px">
{{ tm("status.installed") }}
</v-chip>
</v-card-actions>
</v-card>
</template>
<style scoped>
.plugin-description {
color: rgba(var(--v-theme-on-surface), 0.6);
line-height: 1.3;
margin-bottom: 6px;
flex: 1;
overflow-y: hidden;
}
.plugin-card:hover .plugin-description {
overflow-y: auto;
}
.plugin-description::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.plugin-description::-webkit-scrollbar-track {
background: transparent;
}
.plugin-description::-webkit-scrollbar-thumb {
background-color: rgba(var(--v-theme-primary-rgb), 0.4);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
.plugin-description::-webkit-scrollbar-thumb:hover {
background-color: rgba(var(--v-theme-primary-rgb), 0.6);
}
</style>
@@ -4,7 +4,7 @@
<div class="d-flex align-center ga-2">
<h3 class="mb-0">{{ tm('providerSources.title') }}</h3>
</div>
<v-menu>
<StyledMenu>
<template #activator="{ props }">
<v-btn
v-bind="props"
@@ -17,19 +17,61 @@
{{ tm('providerSources.add') }}
</v-btn>
</template>
<v-list density="compact">
<v-list-item
v-for="sourceType in availableSourceTypes"
:key="sourceType.value"
@click="emitAddSource(sourceType.value)"
>
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-list-item
v-for="sourceType in availableSourceTypes"
:key="sourceType.value"
class="styled-menu-item"
@click="emitAddSource(sourceType.value)"
>
<template #prepend>
<v-avatar size="18" rounded="0" class="me-2">
<v-img v-if="sourceType.icon" :src="sourceType.icon" alt="provider icon" cover></v-img>
<v-icon v-else size="16">mdi-shape-outline</v-icon>
</v-avatar>
</template>
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
</v-list-item>
</StyledMenu>
</div>
<div v-if="displayedProviderSources.length > 0">
<div v-if="isMobile && displayedProviderSources.length > 0" class="px-4 pb-3">
<div class="d-flex align-center ga-2">
<v-select
:model-value="selectedId"
:items="mobileSourceItems"
item-title="label"
item-value="value"
:label="tm('providerSources.selectCreated')"
variant="solo-filled"
density="comfortable"
flat
hide-details
class="mobile-source-select"
@update:model-value="onMobileSourceChange"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #prepend>
<v-avatar size="18" rounded="0" class="me-2">
<v-img v-if="item.raw.icon" :src="item.raw.icon" alt="provider icon" cover></v-img>
<v-icon v-else size="16">mdi-shape-outline</v-icon>
</v-avatar>
</template>
</v-list-item>
</template>
</v-select>
<v-btn
v-if="selectedProviderSource"
icon="mdi-delete"
variant="text"
size="small"
color="error"
@click.stop="emitDeleteSource(selectedProviderSource)"
></v-btn>
</div>
</div>
<div v-else-if="displayedProviderSources.length > 0">
<v-list class="provider-source-list" nav density="compact" lines="two">
<v-list-item
v-for="source in displayedProviderSources"
@@ -46,7 +88,7 @@
<v-icon v-else size="32">mdi-creation</v-icon>
</v-avatar>
</template>
<v-list-item-title class="font-weight-bold">{{ getSourceDisplayName(source) }}</v-list-item-title>
<v-list-item-title class="font-weight-bold mb-1" style="font-family: Arial, Helvetica, sans-serif; font-size: 16px;">{{ getSourceDisplayName(source) }}</v-list-item-title>
<v-list-item-subtitle class="text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
<template #append>
<div class="d-flex align-center ga-1">
@@ -72,6 +114,8 @@
<script setup>
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
import StyledMenu from '@/components/shared/StyledMenu.vue'
const props = defineProps({
displayedProviderSources: {
@@ -106,13 +150,30 @@ const emit = defineEmits([
'delete-provider-source'
])
const { smAndDown } = useDisplay()
const selectedId = computed(() => props.selectedProviderSource?.id || null)
const isMobile = computed(() => smAndDown.value)
const mobileSourceItems = computed(() =>
(props.displayedProviderSources || []).map((source) => ({
value: source.id,
label: props.getSourceDisplayName(source),
icon: props.resolveSourceIcon(source),
source
}))
)
const isActive = (source) => {
if (source.isPlaceholder) return false
return selectedId.value !== null && selectedId.value === source.id
}
const onMobileSourceChange = (sourceId) => {
const matched = mobileSourceItems.value.find((item) => item.value === sourceId)
if (matched?.source) {
emitSelectSource(matched.source)
}
}
const emitAddSource = (type) => emit('add-provider-source', type)
const emitSelectSource = (source) => emit('select-provider-source', source)
const emitDeleteSource = (source) => emit('delete-provider-source', source)
@@ -19,6 +19,10 @@ const props = defineProps({
metadataKey: {
type: String,
required: true
},
searchKeyword: {
type: String,
default: ''
}
})
@@ -124,16 +128,27 @@ function saveEditedContent() {
}
function shouldShowItem(itemMeta, itemKey) {
if (!itemMeta?.condition) {
return true
}
for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) {
const actualValue = getValueBySelector(props.iterable, conditionKey)
if (actualValue !== expectedValue) {
return false
if (itemMeta?.condition) {
for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) {
const actualValue = getValueBySelector(props.iterable, conditionKey)
if (actualValue !== expectedValue) {
return false
}
}
}
return true
const keyword = String(props.searchKeyword || '').trim().toLowerCase()
if (!keyword) {
return true
}
const searchableText = [
itemKey,
translateIfKey(itemMeta?.description || ''),
translateIfKey(itemMeta?.hint || '')
].join(' ').toLowerCase()
return searchableText.includes(keyword)
}
// object
@@ -148,7 +163,10 @@ function shouldShowSection() {
return false
}
}
return true
const sectionItems = props.metadata?.[props.metadataKey]?.items || {}
const hasVisibleItems = Object.entries(sectionItems).some(([itemKey, itemMeta]) => shouldShowItem(itemMeta, itemKey))
return hasVisibleItems
}
function hasVisibleItemsAfter(items, currentIndex) {
@@ -436,9 +454,13 @@ function getSpecialSubtype(value) {
}
.property-info,
.type-indicator,
.type-indicator {
padding: 4px 8px;
}
.config-input {
padding: 4px;
padding-left: 24px;
padding-right: 24px;
}
}
</style>
@@ -10,6 +10,14 @@
<template v-else-if="itemMeta?._special === 'select_provider_tts'">
<ProviderSelector :model-value="modelValue" @update:model-value="emitUpdate" :provider-type="'text_to_speech'" />
</template>
<template v-else-if="itemMeta?._special === 'select_providers'">
<ProviderSelector
:model-value="modelValue"
@update:model-value="emitUpdate"
:provider-type="'chat_completion'"
:multiple="true"
/>
</template>
<template v-else-if="getSpecialName(itemMeta?._special) === 'select_agent_runner_provider'">
<ProviderSelector
:model-value="modelValue"
@@ -27,7 +27,7 @@ export default {
return {
autoScroll: true,
logColorAnsiMap: {
'\u001b[1;34m': 'color: #0000FF; font-weight: bold;',
'\u001b[1;34m': 'color: #39C5BB; font-weight: bold;',
'\u001b[1;36m': 'color: #00FFFF; font-weight: bold;',
'\u001b[1;33m': 'color: #FFFF00; font-weight: bold;',
'\u001b[31m': 'color: #FF0000;',
@@ -1,11 +1,15 @@
<template>
<v-dialog v-model="showDialog" max-width="500px">
<v-card>
<v-card-title class="text-h2">
<v-dialog
v-model="showDialog"
:max-width="$vuetify.display.smAndDown ? undefined : '760px'"
scrollable
>
<v-card class="persona-form-card" :class="{ 'persona-form-card-mobile': $vuetify.display.smAndDown }">
<v-card-title class="persona-form-title text-h2">
{{ editingPersona ? tm('dialog.edit.title') : tm('dialog.create.title') }}
</v-card-title>
<v-card-text>
<v-card-text class="persona-form-content">
<!-- 创建位置提示 -->
<v-alert
v-if="!editingPersona"
@@ -51,7 +55,7 @@
</v-radio>
</v-radio-group>
<div v-if="toolSelectValue === '1'" class="mt-3 ml-8">
<div v-if="toolSelectValue === '1'" class="mt-3 selected-config-area">
<!-- 工具搜索 -->
<v-text-field v-model="toolSearch" :label="tm('form.searchTools')"
@@ -178,7 +182,7 @@
<v-radio :label="tm('form.skillsSelectSpecific')" value="1"></v-radio>
</v-radio-group>
<div v-if="skillSelectValue === '1'" class="mt-3 ml-8">
<div v-if="skillSelectValue === '1'" class="mt-3 selected-config-area">
<v-text-field v-model="skillSearch" :label="tm('form.searchSkills')"
prepend-inner-icon="mdi-magnify" variant="outlined" density="compact"
hide-details clearable class="mb-3" />
@@ -288,7 +292,7 @@
</v-form>
</v-card-text>
<v-card-actions>
<v-card-actions class="persona-form-actions">
<v-btn v-if="editingPersona" color="error" variant="text" @click="deletePersona">
{{ tm('buttons.delete') }}
</v-btn>
@@ -799,6 +803,32 @@ export default {
</script>
<style scoped>
.persona-form-card {
border-radius: 12px;
overflow: hidden;
}
.persona-form-content {
max-height: min(78vh, 760px);
overflow-y: auto;
}
.persona-form-title {
line-height: 1.3;
}
.persona-form-actions {
position: sticky;
bottom: 0;
z-index: 2;
background: rgb(var(--v-theme-surface));
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.selected-config-area {
margin-left: 32px;
}
.tools-selection {
max-height: 300px;
overflow-y: auto;
@@ -812,4 +842,38 @@ export default {
.v-virtual-scroll {
padding-bottom: 16px;
}
@media (max-width: 600px) {
.persona-form-card-mobile {
border-radius: 0;
}
.persona-form-content {
max-height: calc(100vh - 128px);
padding: 16px !important;
}
.persona-form-title {
font-size: 1.15rem !important;
padding: 12px 16px !important;
}
.selected-config-area {
margin-left: 0;
}
.tools-selection,
.skills-selection {
max-height: 38vh;
}
.persona-form-actions {
padding: 12px 16px !important;
gap: 8px;
}
.persona-form-actions .v-btn {
min-width: 0;
}
}
</style>
@@ -1,16 +1,35 @@
<template>
<div class="d-flex align-center justify-space-between">
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
<span v-if="!hasSelection" style="color: rgb(var(--v-theme-primaryText));">
{{ tm('providerSelector.notSelected') }}
</span>
<span v-else class="provider-name-text">
{{ modelValue }}
<template v-if="multiple">
{{ tm('providerSelector.selectedCount', { count: selectedProviders.length }) }}
</template>
<template v-else>
{{ modelValue }}
</template>
</span>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
{{ buttonText || tm('providerSelector.buttonText') }}
</v-btn>
</div>
<div v-if="multiple && selectedProviders.length > 0" class="selected-preview mt-2">
<v-chip
v-for="providerId in selectedProviders"
:key="`preview-${providerId}`"
size="x-small"
color="primary"
variant="tonal"
class="mr-1 mb-1"
label
>
{{ providerId }}
</v-chip>
</div>
<!-- Provider Selection Dialog -->
<v-dialog v-model="dialog" max-width="600px">
<v-card>
@@ -32,10 +51,52 @@
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<div v-if="multiple && selectedProviders.length > 0" class="pa-3">
<div class="text-caption text-medium-emphasis mb-2">
{{ tm('providerSelector.selectedCount', { count: selectedProviders.length }) }}
</div>
<v-list density="compact" class="selected-order-list">
<v-list-item
v-for="(providerId, index) in selectedProviders"
:key="`selected-${providerId}-${index}`"
rounded="md"
class="ma-1"
>
<v-list-item-title>{{ providerId }}</v-list-item-title>
<template #append>
<div class="d-flex ga-1">
<v-btn
icon="mdi-arrow-up"
size="x-small"
variant="text"
:disabled="index === 0"
@click.stop="moveSelected(index, -1)"
/>
<v-btn
icon="mdi-arrow-down"
size="x-small"
variant="text"
:disabled="index === selectedProviders.length - 1"
@click.stop="moveSelected(index, 1)"
/>
<v-btn
icon="mdi-close"
size="x-small"
variant="text"
@click.stop="removeSelected(providerId)"
/>
</div>
</template>
</v-list-item>
</v-list>
<v-divider class="ma-1"></v-divider>
</div>
<v-list v-if="!loading && providerList.length > 0" density="compact">
<!-- 不选择选项 -->
<v-list-item
v-if="!multiple"
key="none"
value=""
@click="selectProvider({ id: '' })"
@@ -57,7 +118,7 @@
:key="provider.id"
:value="provider.id"
@click="selectProvider(provider)"
:active="selectedProvider === provider.id"
:active="isProviderSelected(provider.id)"
rounded="md"
class="ma-1">
<v-list-item-title>{{ provider.id }}</v-list-item-title>
@@ -67,7 +128,7 @@
</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedProvider === provider.id" color="primary">mdi-check-circle</v-icon>
<v-icon v-if="isProviderSelected(provider.id)" color="primary">mdi-check-circle</v-icon>
</template>
</v-list-item>
</v-list>
@@ -121,7 +182,7 @@ import ProviderPage from '@/views/ProviderPage.vue'
const props = defineProps({
modelValue: {
type: String,
type: [String, Array],
default: ''
},
providerType: {
@@ -135,6 +196,10 @@ const props = defineProps({
buttonText: {
type: String,
default: ''
},
multiple: {
type: Boolean,
default: false
}
})
@@ -145,8 +210,16 @@ const dialog = ref(false)
const providerList = ref([])
const loading = ref(false)
const selectedProvider = ref('')
const selectedProviders = ref([])
const providerDrawer = ref(false)
const hasSelection = computed(() => {
if (props.multiple) {
return selectedProviders.value.length > 0
}
return Boolean(props.modelValue)
})
const defaultTab = computed(() => {
if (props.providerType === 'agent_runner' && props.providerSubtype) {
return `select_agent_runner_provider:${props.providerSubtype}`
@@ -156,7 +229,13 @@ const defaultTab = computed(() => {
// modelValue selectedProvider
watch(() => props.modelValue, (newValue) => {
selectedProvider.value = newValue || ''
if (props.multiple) {
selectedProviders.value = Array.isArray(newValue)
? [...newValue.filter((v) => typeof v === 'string' && v)]
: []
return
}
selectedProvider.value = typeof newValue === 'string' ? newValue : ''
}, { immediate: true })
watch(providerDrawer, (isOpen, wasOpen) => {
@@ -166,7 +245,13 @@ watch(providerDrawer, (isOpen, wasOpen) => {
})
async function openDialog() {
selectedProvider.value = props.modelValue || ''
if (props.multiple) {
selectedProviders.value = Array.isArray(props.modelValue)
? [...props.modelValue.filter((v) => typeof v === 'string' && v)]
: []
} else {
selectedProvider.value = typeof props.modelValue === 'string' ? props.modelValue : ''
}
dialog.value = true
await loadProviders()
}
@@ -205,19 +290,72 @@ function matchesProviderSubtype(provider, subtype) {
}
function selectProvider(provider) {
if (props.multiple) {
if (!provider.id) {
selectedProviders.value = []
return
}
const idx = selectedProviders.value.indexOf(provider.id)
if (idx >= 0) {
selectedProviders.value.splice(idx, 1)
} else {
selectedProviders.value.push(provider.id)
}
return
}
selectedProvider.value = provider.id
}
function confirmSelection() {
emit('update:modelValue', selectedProvider.value)
if (props.multiple) {
emit('update:modelValue', [...selectedProviders.value])
} else {
emit('update:modelValue', selectedProvider.value)
}
dialog.value = false
}
function cancelSelection() {
selectedProvider.value = props.modelValue || ''
if (props.multiple) {
selectedProviders.value = Array.isArray(props.modelValue)
? [...props.modelValue.filter((v) => typeof v === 'string' && v)]
: []
} else {
selectedProvider.value = typeof props.modelValue === 'string' ? props.modelValue : ''
}
dialog.value = false
}
function isProviderSelected(providerId) {
if (props.multiple) {
return selectedProviders.value.includes(providerId)
}
return selectedProvider.value === providerId
}
function removeSelected(providerId) {
const idx = selectedProviders.value.indexOf(providerId)
if (idx >= 0) {
selectedProviders.value.splice(idx, 1)
}
}
function moveSelected(index, delta) {
const targetIndex = index + delta
if (
targetIndex < 0
|| targetIndex >= selectedProviders.value.length
|| index < 0
|| index >= selectedProviders.value.length
) {
return
}
const copied = [...selectedProviders.value]
const [item] = copied.splice(index, 1)
copied.splice(targetIndex, 0, item)
selectedProviders.value = copied
}
function openProviderDrawer() {
providerDrawer.value = true
}
@@ -236,6 +374,16 @@ function closeProviderDrawer() {
display: inline-block;
}
.selected-preview {
width: 100%;
max-width: 100%;
}
.selected-order-list {
background: rgba(var(--v-theme-surface-variant), 0.15);
border-radius: 10px;
}
.v-list-item {
transition: all 0.2s ease;
}
@@ -81,10 +81,14 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
return []
}
const types: Array<{ value: string; label: string }> = []
const types: Array<{ value: string; label: string; icon: string }> = []
for (const [templateName, template] of Object.entries(providerTemplates.value)) {
if (template.provider_type === selectedProviderType.value) {
types.push({ value: templateName, label: templateName })
types.push({
value: templateName,
label: templateName,
icon: getProviderIcon(template.provider)
})
}
}
@@ -28,6 +28,7 @@
"settings": "Settings",
"changelog": "Changelog",
"documentation": "Documentation",
"faq": "FAQ",
"github": "GitHub",
"drag": "Drag",
"groups": {
@@ -45,7 +45,8 @@
"unknownType": "Unknown type",
"createProvider": "Create Provider",
"manageProviders": "Provider Management",
"selectProviderPool": "Select Provider Pool..."
"selectProviderPool": "Select Provider Pool...",
"selectedCount": "{count} provider(s) selected"
},
"personaSelector": {
"notSelected": "Not selected",
@@ -37,6 +37,10 @@
"description": "Default Chat Model",
"hint": "Uses the first model when left empty"
},
"fallback_chat_models": {
"description": "Fallback chat model IDs",
"hint": "When the primary chat model request fails, fallback to these chat models in order."
},
"default_image_caption_provider_id": {
"description": "Default Image Caption Model",
"hint": "Leave empty to disable; useful for non-multimodal models"
@@ -869,6 +873,26 @@
"description": "Externally Accessible Callback API Address",
"hint": "External services may access AstrBot's backend through callback links generated by AstrBot (such as file download links). Since AstrBot cannot automatically determine the externally accessible host address in the deployment environment, this configuration item is needed to explicitly specify how external services should access AstrBot's address. Examples: [http://localhost:6185](http://localhost:6185), [https://example.com](https://example.com), etc."
},
"dashboard": {
"ssl": {
"enable": {
"description": "Enable WebUI HTTPS",
"hint": "When enabled, WebUI serves directly over HTTPS."
},
"cert_file": {
"description": "SSL Certificate File Path",
"hint": "Certificate file path (PEM). Supports absolute and relative paths (relative to current working directory)."
},
"key_file": {
"description": "SSL Private Key File Path",
"hint": "Private key file path (PEM). Supports absolute and relative paths (relative to current working directory)."
},
"ca_certs": {
"description": "SSL CA Certificate File Path",
"hint": "Optional. Path to CA certificate file."
}
}
},
"timezone": {
"description": "Timezone",
"hint": "Timezone setting. Please enter an IANA timezone name, such as Asia/Shanghai. Uses system default timezone when empty. For all timezones, see: [https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab](https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab)"
@@ -28,6 +28,7 @@
"messages": {
"configApplied": "Configuration successfully applied. To save, you need to click the save button in the bottom right corner.",
"configApplyError": "Configuration not applied, JSON format error.",
"unsavedChangesNotice": "You have unsaved configuration changes. Click the save button in the bottom-right corner to apply them.",
"saveSuccess": "Configuration saved successfully",
"saveError": "Failed to save configuration",
"loadError": "Failed to load configuration",
@@ -68,6 +69,10 @@
"normalConfig": "Basic",
"systemConfig": "System"
},
"search": {
"placeholder": "Search config items (key/description/hint)",
"noResult": "No matching config items found"
},
"configManagement": {
"title": "Configuration Management",
"description": "AstrBot supports separate configuration files for different bots. The `default` configuration is used by default.",
@@ -38,7 +38,8 @@
"selectFile": "Select File",
"refresh": "Refresh",
"updateAll": "Update All",
"deleteSource": "Delete Source"
"deleteSource": "Delete Source",
"reshuffle": "Shuffle Again"
},
"status": {
"enabled": "Enabled",
@@ -103,7 +104,9 @@
"sourceUpdated": "Source updated successfully",
"defaultOfficialSource": "Default Official Source",
"sourceExists": "This source already exists",
"installPlugin": "Install Plugin"
"installPlugin": "Install Plugin",
"randomPlugins": "🎲 Random Plugins",
"sourceSafetyWarning": "Even with the default source, plugin stability and security cannot be fully guaranteed. Please verify carefully before use."
},
"sort": {
"default": "Default",
@@ -94,6 +94,7 @@
"add": "Add",
"empty": "No provider sources",
"selectHint": "Please select a provider source",
"selectCreated": "Select created provider source",
"save": "Save Configuration",
"saveAndFetchModels": "Save and Fetch Models",
"fetchModels": "Fetch Model List",
@@ -146,4 +147,4 @@
"modelId": "Model ID"
}
}
}
}
@@ -6,6 +6,9 @@
"newYear": "Happy New Year!"
},
"subtitle": "You can complete the basic onboarding first. Platform and chat provider setup can both be skipped.",
"announcement": {
"title": "Announcement"
},
"onboard": {
"title": "Quick Onboarding",
"subtitle": "Complete initialization directly on the welcome page.",
@@ -27,6 +30,8 @@
"title": "Resources",
"githubDesc": "Give us a Star!",
"docsTitle": "Documentation",
"docsDesc": "Read the official AstrBot documentation."
"docsDesc": "Read the official AstrBot documentation.",
"afdianTitle": "Afdian",
"afdianDesc": "Support the AstrBot team on Afdian."
}
}
@@ -28,6 +28,7 @@
"settings": "设置",
"changelog": "更新日志",
"documentation": "官方文档",
"faq": "FAQ",
"github": "GitHub",
"drag": "拖拽",
"groups": {
@@ -45,7 +45,8 @@
"unknownType": "未知类型",
"createProvider": "创建提供商",
"manageProviders": "提供商管理",
"selectProviderPool": "选择提供商池..."
"selectProviderPool": "选择提供商池...",
"selectedCount": "已选择 {count} 个提供商"
},
"personaSelector": {
"notSelected": "未选择",
@@ -31,12 +31,16 @@
},
"ai": {
"description": "模型",
"hint": "当使用非内置 Agent 执行器时,默认聊天模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。",
"hint": "当使用非内置 Agent 执行器时,默认对话模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。",
"provider_settings": {
"default_provider_id": {
"description": "默认聊天模型",
"description": "默认对话模型",
"hint": "留空时使用第一个模型"
},
"fallback_chat_models": {
"description": "回退对话模型列表",
"hint": "主对话模型请求失败时,按顺序切换到这些对话模型。"
},
"default_image_caption_provider_id": {
"description": "默认图片转述模型",
"hint": "留空代表不使用,可用于非多模态模型"
@@ -872,6 +876,26 @@
"description": "对外可达的回调接口地址",
"hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址(host),因此需要通过此配置项显式指定外部服务如何访问 AstrBot 的地址。如 [http://localhost:6185](http://localhost:6185),[https://example.com](https://example.com) 等。"
},
"dashboard": {
"ssl": {
"enable": {
"description": "启用 WebUI HTTPS",
"hint": "启用后,WebUI 将直接使用 HTTPS 提供服务。"
},
"cert_file": {
"description": "SSL 证书文件路径",
"hint": "证书文件路径(PEM)。支持绝对路径和相对路径(相对于当前工作目录)。"
},
"key_file": {
"description": "SSL 私钥文件路径",
"hint": "私钥文件路径(PEM)。支持绝对路径和相对路径(相对于当前工作目录)。"
},
"ca_certs": {
"description": "SSL CA 证书文件路径",
"hint": "可选。用于指定 CA 证书文件路径。"
}
}
},
"timezone": {
"description": "时区",
"hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: [https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab](https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab)"
@@ -28,6 +28,7 @@
"messages": {
"configApplied": "配置成功应用。如要保存,需再点击右下角保存按钮。",
"configApplyError": "配置未应用,Json 格式错误。",
"unsavedChangesNotice": "当前配置有未保存修改。请点击右下角保存按钮以生效。",
"saveSuccess": "配置保存成功",
"saveError": "配置保存失败",
"loadError": "配置加载失败",
@@ -68,6 +69,10 @@
"normalConfig": "普通",
"systemConfig": "系统"
},
"search": {
"placeholder": "搜索配置项(字段名/描述/提示)",
"noResult": "未找到匹配的配置项"
},
"configManagement": {
"title": "配置文件管理",
"description": "AstrBot 支持针对不同机器人分别设置配置文件。默认会使用 `default` 配置。",
@@ -3,7 +3,7 @@
"subtitle": "管理和配置系统插件",
"tabs": {
"installedPlugins": "AstrBot 插件",
"market": "AstrBot 插件市场",
"market": "AstrBot 插件市场",
"installedMcpServers": "MCP",
"skills": "Skills",
"handlersOperation": "管理行为"
@@ -38,7 +38,8 @@
"selectFile": "选择文件",
"refresh": "刷新",
"updateAll": "更新全部插件",
"deleteSource": "删除源"
"deleteSource": "删除源",
"reshuffle": "随机一发"
},
"status": {
"enabled": "启用",
@@ -103,7 +104,9 @@
"sourceUpdated": "插件源更新成功",
"defaultOfficialSource": "默认官方源",
"sourceExists": "该插件源已存在",
"installPlugin": "安装插件"
"installPlugin": "安装插件",
"randomPlugins": "🎲 随机插件",
"sourceSafetyWarning": "即使是默认插件源,我们也不能完全保证插件的稳定性和安全性,使用前请谨慎核查。"
},
"sort": {
"default": "默认排序",
@@ -95,6 +95,7 @@
"add": "新增",
"empty": "暂无提供商源",
"selectHint": "请选择一个提供商源",
"selectCreated": "选择已创建的提供商源",
"save": "保存配置",
"saveAndFetchModels": "保存并获取模型",
"fetchModels": "获取模型列表",
@@ -147,4 +148,4 @@
"modelId": "模型 ID"
}
}
}
}
@@ -6,6 +6,9 @@
"newYear": "新年快乐!"
},
"subtitle": "可以先完成基础引导,平台和对话提供商都支持稍后再配置。",
"announcement": {
"title": "公告"
},
"onboard": {
"title": "快速引导",
"subtitle": "欢迎页可直接完成初始化。",
@@ -27,6 +30,8 @@
"title": "相关资源",
"githubDesc": "给 AstrBot 点个 Star 吧!",
"docsTitle": "文档",
"docsDesc": "查阅 AstrBot 的官方文档。"
"docsDesc": "查阅 AstrBot 的官方文档。",
"afdianTitle": "爱发电",
"afdianDesc": "通过爱发电支持 AstrBot 团队。"
}
}
@@ -7,7 +7,7 @@ import NavItem from './NavItem.vue';
import { applySidebarCustomization } from '@/utils/sidebarCustomization';
import ChangelogDialog from '@/components/shared/ChangelogDialog.vue';
const { t } = useI18n();
const { t, locale } = useI18n();
const customizer = useCustomizerStore();
const sidebarMenu = shallowRef(sidebarItems);
@@ -109,6 +109,13 @@ function openIframeLink(url) {
}
}
function openFaqLink() {
const faqUrl = locale.value === 'en-US'
? 'https://docs.astrbot.app/en/faq.html'
: 'https://docs.astrbot.app/faq.html';
openIframeLink(faqUrl);
}
let offsetX = 0;
let offsetY = 0;
let isDragging = false;
@@ -264,6 +271,10 @@ function openChangelogDialog() {
@click="toggleIframe">
{{ t('core.navigation.documentation') }}
</v-btn>
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-frequently-asked-questions"
@click="openFaqLink">
{{ t('core.navigation.faq') }}
</v-btn>
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-github"
@click="openIframeLink('https://github.com/AstrBotDevs/AstrBot')">
{{ t('core.navigation.github') }}
+2
View File
@@ -34,6 +34,8 @@ export function getPlatformIcon(name) {
return new URL('@/assets/images/platform_logos/satori.png', import.meta.url).href
} else if (name === 'misskey') {
return new URL('@/assets/images/platform_logos/misskey.png', import.meta.url).href
} else if (name === 'line') {
return new URL('@/assets/images/platform_logos/line.png', import.meta.url).href
}
}
+2
View File
@@ -18,6 +18,7 @@ export function getProviderIcon(type) {
'deepseek': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek.svg',
'modelscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/modelscope.svg',
'zhipu': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/zhipu.svg',
'nvidia': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/nvidia-color.svg',
'siliconflow': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/siliconcloud.svg',
'moonshot': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg',
'ppio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ppio.svg',
@@ -32,6 +33,7 @@ export function getProviderIcon(type) {
'microsoft': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/microsoft.svg',
'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg',
'groq': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg',
'aihubmix': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/aihubmix-color.svg',
"tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png",
"compshare": "https://compshare.cn/favicon.ico"
};
+75 -3
View File
@@ -4,17 +4,39 @@
<div v-if="selectedConfigID || isSystemConfig" class="mt-4 config-panel"
style="display: flex; flex-direction: column; align-items: start;">
<div class="d-flex flex-row pr-4"
<div class="config-toolbar d-flex flex-row pr-4"
style="margin-bottom: 16px; align-items: center; gap: 12px; width: 100%; justify-content: space-between;">
<div class="d-flex flex-row align-center" style="gap: 12px;">
<v-select style="min-width: 130px;" v-model="selectedConfigID" :items="configSelectItems" item-title="name" :disabled="initialConfigId !== null"
<div class="config-toolbar-controls d-flex flex-row align-center" style="gap: 12px;">
<v-select class="config-select" style="min-width: 130px;" v-model="selectedConfigID" :items="configSelectItems" item-title="name" :disabled="initialConfigId !== null"
v-if="!isSystemConfig" item-value="id" :label="tm('configSelection.selectConfig')" hide-details density="compact" rounded="md"
variant="outlined" @update:model-value="onConfigSelect">
</v-select>
<v-text-field
class="config-search-input"
v-model="configSearchKeyword"
prepend-inner-icon="mdi-magnify"
:label="tm('search.placeholder')"
hide-details
density="compact"
rounded="md"
variant="outlined"
style="min-width: 280px;"
/>
<!-- <a style="color: inherit;" href="https://blog.astrbot.app/posts/what-is-changed-in-4.0.0/#%E5%A4%9A%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6" target="_blank"><v-btn icon="mdi-help-circle" size="small" variant="plain"></v-btn></a> -->
</div>
</div>
<v-slide-y-transition>
<div v-if="fetched && hasUnsavedChanges" class="unsaved-changes-banner-wrap">
<v-banner
icon="$warning"
lines="one"
class="unsaved-changes-banner my-4"
>
{{ tm('messages.unsavedChangesNotice') }}
</v-banner>
</div>
</v-slide-y-transition>
<!-- <v-progress-linear v-if="!fetched" indeterminate color="primary"></v-progress-linear> -->
<v-slide-y-transition mode="out-in">
@@ -23,6 +45,7 @@
<AstrBotCoreConfigWrapper
:metadata="metadata"
:config_data="config_data"
:search-keyword="configSearchKeyword"
/>
<v-tooltip :text="tm('actions.save')" location="left">
@@ -235,6 +258,12 @@ export default {
});
return items;
},
hasUnsavedChanges() {
if (!this.fetched) {
return false;
}
return this.getConfigSnapshot(this.config_data) !== this.lastSavedConfigSnapshot;
}
},
watch: {
config_data_str(val) {
@@ -269,9 +298,11 @@ export default {
save_message: "",
save_message_success: "",
configContentKey: 0,
lastSavedConfigSnapshot: '',
//
configType: 'normal', // 'normal' 'system'
configSearchKeyword: '',
//
isSystemConfig: false,
@@ -383,6 +414,7 @@ export default {
params: params
}).then((res) => {
this.config_data = res.data.data.config;
this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.fetched = true
this.metadata = res.data.data.metadata;
this.configContentKey += 1;
@@ -407,6 +439,7 @@ export default {
axios.post('/api/config/astrbot/update', postData).then((res) => {
if (res.data.status === "ok") {
this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.save_message = res.data.message || this.messages.saveSuccess;
this.save_message_snack = true;
this.save_message_success = "success";
@@ -470,6 +503,7 @@ export default {
//
this.$nextTick(() => {
this.selectedConfigID = this.selectedConfigInfo.id || 'default';
this.getConfig(this.selectedConfigID);
});
} else {
this.getConfig(value);
@@ -601,6 +635,9 @@ export default {
closeTestChat() {
this.testChatDrawer = false;
this.testConfigId = null;
},
getConfigSnapshot(config) {
return JSON.stringify(config ?? {});
}
},
}
@@ -612,6 +649,26 @@ export default {
text-transform: none !important;
}
.unsaved-changes-banner {
border-radius: 8px;
}
.v-theme--light .unsaved-changes-banner {
background-color: #f1f4f9 !important;
}
.v-theme--dark .unsaved-changes-banner {
background-color: #2d2d2d !important;
}
.unsaved-changes-banner-wrap {
position: sticky;
top: calc(var(--v-layout-top, 64px));
z-index: 20;
width: 100%;
margin-bottom: 6px;
}
/* 按钮切换样式优化 */
.v-btn-toggle .v-btn {
transition: all 0.3s ease !important;
@@ -659,6 +716,21 @@ export default {
.config-panel {
width: 100%;
}
.config-toolbar {
padding-right: 0 !important;
}
.config-toolbar-controls {
width: 100%;
flex-wrap: wrap;
}
.config-select,
.config-search-input {
width: 100%;
min-width: 0 !important;
}
}
/* 测试聊天抽屉样式 */
+5 -4
View File
@@ -55,11 +55,12 @@
<template #item.last_run_at="{ item }">{{ formatTime(item.last_run_at) }}</template>
<template #item.note="{ item }">{{ item.note || tm('table.notAvailable') }}</template>
<template #item.actions="{ item }">
<div class="d-flex" style="gap: 8px;">
<div class="d-flex align-center flex-nowrap" style="gap: 12px; min-width: 140px;">
<v-switch v-model="item.enabled" inset density="compact" hide-details color="primary"
@change="toggleJob(item)" />
<v-btn size="small" variant="text" color="primary" @click="deleteJob(item)">{{ tm('actions.delete')
}}</v-btn>
class="mt-0" @change="toggleJob(item)" />
<v-btn size="small" variant="text" color="error" @click="deleteJob(item)">
{{ tm('actions.delete') }}
</v-btn>
</div>
</template>
</v-data-table>
+143 -299
View File
@@ -7,6 +7,7 @@ import ProxySelector from "@/components/shared/ProxySelector.vue";
import UninstallConfirmDialog from "@/components/shared/UninstallConfirmDialog.vue";
import McpServersSection from "@/components/extension/McpServersSection.vue";
import SkillsSection from "@/components/extension/SkillsSection.vue";
import MarketPluginCard from "@/components/extension/MarketPluginCard.vue";
import ComponentPanel from "@/components/extension/componentPanel/index.vue";
import axios from "axios";
import { pinyin } from "pinyin-pro";
@@ -175,6 +176,7 @@ const debouncedMarketSearch = ref("");
const refreshingMarket = ref(false);
const sortBy = ref("default"); // default, stars, author, updated
const sortOrder = ref("desc"); // desc () or asc ()
const randomPluginNames = ref([]);
//
const normalizeStr = (s) => (s ?? "").toString().toLowerCase().trim();
@@ -310,8 +312,42 @@ const sortedPlugins = computed(() => {
return plugins;
});
const RANDOM_PLUGINS_COUNT = 6;
const randomPlugins = computed(() => {
const allPlugins = pluginMarketData.value;
if (allPlugins.length === 0) return [];
const pluginsByName = new Map(allPlugins.map((plugin) => [plugin.name, plugin]));
const selected = randomPluginNames.value
.map((name) => pluginsByName.get(name))
.filter(Boolean);
if (selected.length > 0) {
return selected;
}
return allPlugins.slice(0, Math.min(RANDOM_PLUGINS_COUNT, allPlugins.length));
});
const shufflePlugins = (plugins) => {
const shuffled = [...plugins];
for (let i = shuffled.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
};
const refreshRandomPlugins = () => {
const shuffled = shufflePlugins(pluginMarketData.value);
randomPluginNames.value = shuffled
.slice(0, Math.min(RANDOM_PLUGINS_COUNT, shuffled.length))
.map((plugin) => plugin.name);
};
//
const displayItemsPerPage = 9; // 62
const displayItemsPerPage = 9; // 93
const totalPages = computed(() => {
return Math.ceil(sortedPlugins.value.length / displayItemsPerPage);
@@ -357,11 +393,17 @@ const onLoadingDialogResult = (statusCode, result, timeToClose = 2000) => {
setTimeout(resetLoadingDialog, timeToClose);
};
const failedPluginsDict = ref({});
const getExtensions = async () => {
loading_.value = true;
try {
const res = await axios.get("/api/plugin/get");
const res = await axios.get("/api/plugin/get");
Object.assign(extension_data, res.data);
const failRes = await axios.get("/api/plugin/source/get-failed-plugins");
failedPluginsDict.value = failRes.data.data || {};
checkUpdate();
} catch (err) {
toast(err, "error");
@@ -370,6 +412,36 @@ const getExtensions = async () => {
}
};
const handleReloadAllFailed = async () => {
const dirNames = Object.keys(failedPluginsDict.value);
if (dirNames.length === 0) {
toast("没有需要重载的失败插件", "info");
return;
}
loading_.value = true;
try {
const promises = dirNames.map(dir =>
axios.post("/api/plugin/reload-failed", { dir_name: dir })
);
await Promise.all(promises);
toast("已尝试重载所有失败插件", "success");
// message
extension_data.message = "";
//
await getExtensions();
} catch (e) {
console.error("重载失败:", e);
toast("批量重载过程中出现错误", "error");
} finally {
loading_.value = false;
}
};
const checkUpdate = () => {
const onlinePluginsMap = new Map();
const onlinePluginsNameMap = new Map();
@@ -1001,6 +1073,7 @@ const refreshPluginMarket = async () => {
trimExtensionName();
checkAlreadyInstalled();
checkUpdate();
refreshRandomPlugins();
currentPage.value = 1; //
toast(tm("messages.refreshSuccess"), "success");
@@ -1049,6 +1122,7 @@ onMounted(async () => {
trimExtensionName();
checkAlreadyInstalled();
checkUpdate();
refreshRandomPlugins();
} catch (err) {
toast(tm("messages.getMarketDataFailed") + " " + err, "error");
}
@@ -1273,6 +1347,15 @@ watch(activeTab, (newTab) => {
</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"
@@ -1743,17 +1826,21 @@ watch(activeTab, (newTab) => {
</v-list-item>
</v-list>
</v-menu>
</div>
<!-- 垂直分隔线 -->
<div
style="
height: 20px;
width: 1px;
background-color: rgba(var(--v-border-color), 0.15);
margin: 0 8px;
"
></div>
<div
class="d-flex align-center ml-2"
style="
color: grey;
font-size: 12px;
line-height: 1.3;
white-space: normal;
text-align: left;
"
>
<v-icon size="16" class="mr-1">mdi-alert-outline</v-icon>
<span>{{ tm("market.sourceSafetyWarning") }}</span>
</div>
</div>
<!--右侧操作按钮组-->
<div class="d-flex align-center">
@@ -1838,6 +1925,42 @@ watch(activeTab, (newTab) => {
</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="
@@ -1874,7 +1997,6 @@ watch(activeTab, (newTab) => {
density="comfortable"
></v-pagination>
<!-- 排序选择器 -->
<v-select
v-model="sortBy"
:items="[
@@ -1893,7 +2015,6 @@ watch(activeTab, (newTab) => {
</template>
</v-select>
<!-- 排序方向切换按钮 -->
<v-btn
icon
v-if="sortBy !== 'default'"
@@ -1914,272 +2035,27 @@ watch(activeTab, (newTab) => {
}}
</v-tooltip>
</v-btn>
<!-- <v-switch v-model="showPluginFullName" :label="tm('market.showFullName')" hide-details
density="compact" style="margin-left: 12px" /> -->
</div>
</div>
<v-row style="min-height: 26rem">
<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"
>
<v-card
class="rounded-lg d-flex flex-column plugin-card"
elevation="0"
style="height: 12rem; position: relative"
>
<!-- 推荐标记 -->
<v-chip
v-if="plugin?.pinned"
color="warning"
size="x-small"
label
style="
position: absolute;
right: 8px;
top: 8px;
z-index: 10;
height: 20px;
font-weight: bold;
"
>
🥳 推荐
</v-chip>
<v-card-text
style="
padding: 12px;
padding-bottom: 8px;
display: flex;
gap: 12px;
width: 100%;
flex: 1;
overflow: hidden;
"
>
<div style="flex-shrink: 0">
<img
:src="plugin?.logo || defaultPluginIcon"
:alt="plugin.name"
style="
height: 75px;
width: 75px;
border-radius: 8px;
object-fit: cover;
"
/>
</div>
<div
style="
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
"
>
<!-- Display Name -->
<div
class="font-weight-bold"
style="
margin-bottom: 4px;
line-height: 1.3;
font-size: 1.2rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
"
>
<span
style="overflow: hidden; text-overflow: ellipsis"
>
{{
plugin.display_name?.length
? plugin.display_name
: showPluginFullName
? plugin.name
: plugin.trimmedName
}}
</span>
</div>
<!-- Author with link -->
<div
class="d-flex align-center"
style="gap: 4px; margin-bottom: 6px"
>
<v-icon
icon="mdi-account"
size="x-small"
style="color: rgba(var(--v-theme-on-surface), 0.5)"
></v-icon>
<a
v-if="plugin?.social_link"
:href="plugin.social_link"
target="_blank"
class="text-subtitle-2 font-weight-medium"
style="
text-decoration: none;
color: rgb(var(--v-theme-primary));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
"
>
{{ plugin.author }}
</a>
<span
v-else
class="text-subtitle-2 font-weight-medium"
style="
color: rgb(var(--v-theme-primary));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
"
>
{{ plugin.author }}
</span>
<div
class="d-flex align-center text-subtitle-2 ml-2"
style="color: rgba(var(--v-theme-on-surface), 0.7)"
>
<v-icon
icon="mdi-source-branch"
size="x-small"
style="margin-right: 2px"
></v-icon>
<span>{{ plugin.version }}</span>
</div>
</div>
<!-- Description -->
<div class="text-caption plugin-description">
{{ plugin.desc }}
</div>
<!-- Stats: Stars & Updated & Version -->
<div
class="d-flex align-center"
style="gap: 8px; margin-top: auto"
>
<div
v-if="plugin.stars !== undefined"
class="d-flex align-center text-subtitle-2"
style="color: rgba(var(--v-theme-on-surface), 0.7)"
>
<v-icon
icon="mdi-star"
size="x-small"
style="margin-right: 2px"
></v-icon>
<span>{{ plugin.stars }}</span>
</div>
<div
v-if="plugin.updated_at"
class="d-flex align-center text-subtitle-2"
style="color: rgba(var(--v-theme-on-surface), 0.7)"
>
<v-icon
icon="mdi-clock-outline"
size="x-small"
style="margin-right: 2px"
></v-icon>
<span>{{
new Date(plugin.updated_at).toLocaleString()
}}</span>
</div>
</div>
</div>
</v-card-text>
<!-- Actions -->
<v-card-actions
style="gap: 6px; padding: 8px 12px; padding-top: 0"
>
<v-chip
v-for="tag in plugin.tags?.slice(0, 2)"
:key="tag"
:color="tag === 'danger' ? 'error' : 'primary'"
label
size="x-small"
style="height: 20px"
>
{{ tag === "danger" ? tm("tags.danger") : tag }}
</v-chip>
<v-menu
v-if="plugin.tags && plugin.tags.length > 2"
open-on-hover
offset-y
>
<template v-slot:activator="{ props: menuProps }">
<v-chip
v-bind="menuProps"
color="grey"
label
size="x-small"
style="height: 20px; cursor: pointer"
>
+{{ plugin.tags.length - 2 }}
</v-chip>
</template>
<v-list density="compact">
<v-list-item
v-for="tag in plugin.tags.slice(2)"
:key="tag"
>
<v-chip
:color="tag === 'danger' ? 'error' : 'primary'"
label
size="small"
>
{{ tag === "danger" ? tm("tags.danger") : tag }}
</v-chip>
</v-list-item>
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-btn
v-if="plugin?.repo"
color="secondary"
size="x-small"
variant="tonal"
:href="plugin.repo"
target="_blank"
style="height: 24px"
>
<v-icon icon="mdi-github" start size="x-small"></v-icon>
{{ tm("buttons.viewRepo") }}
</v-btn>
<v-btn
v-if="!plugin?.installed"
color="primary"
size="x-small"
@click="handleInstallPlugin(plugin)"
variant="flat"
style="height: 24px"
>
{{ tm("buttons.install") }}
</v-btn>
<v-chip
v-else
color="success"
size="x-small"
label
style="height: 20px"
>
{{ tm("status.installed") }}
</v-chip>
</v-card-actions>
</v-card>
<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"
@@ -2684,38 +2560,6 @@ watch(activeTab, (newTab) => {
background-color: #f5f5f5;
}
.plugin-description {
color: rgba(var(--v-theme-on-surface), 0.6);
line-height: 1.3;
margin-bottom: 6px;
flex: 1;
overflow-y: hidden;
}
.plugin-card:hover .plugin-description {
overflow-y: auto;
}
.plugin-description::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.plugin-description::-webkit-scrollbar-track {
background: transparent;
}
.plugin-description::-webkit-scrollbar-thumb {
background-color: rgba(var(--v-theme-primary-rgb), 0.4);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
.plugin-description::-webkit-scrollbar-thumb:hover {
background-color: rgba(var(--v-theme-primary-rgb), 0.6);
}
.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);
+84 -3
View File
@@ -70,7 +70,7 @@
{{ tm('resources.title') }}
</div>
<v-row>
<v-col cols="12" sm="6">
<v-col cols="12" sm="4">
<!-- GitHub Card -->
<v-card variant="outlined" class="h-100 pa-4 d-flex flex-column"
href="https://github.com/AstrBotDevs/AstrBot/" target="_blank">
@@ -84,7 +84,7 @@
</v-card>
</v-col>
<v-col cols="12" sm="6">
<v-col cols="12" sm="4">
<!-- Docs Card -->
<v-card variant="outlined" class="h-100 pa-4 d-flex flex-column" href="https://docs.astrbot.app"
target="_blank">
@@ -98,10 +98,39 @@
</v-card>
</v-col>
<v-col cols="12" sm="4">
<!-- Afdian Card -->
<v-card variant="outlined" class="h-100 pa-4 d-flex flex-column"
href="https://afdian.com/a/astrbot_team" target="_blank">
<div class="d-flex align-center mb-3">
<v-icon size="32" class="mr-3">mdi-hand-heart</v-icon>
<span class="text-h6 font-weight-bold">{{ tm('resources.afdianTitle') }}</span>
</div>
<p class="text-body-2 text-medium-emphasis mb-0">
{{ tm('resources.afdianDesc') }}
</p>
</v-card>
</v-col>
</v-row>
</v-card>
</v-col>
</v-row>
<v-row v-if="showAnnouncement" class="px-4 mb-4">
<v-col cols="12">
<v-card class="welcome-card pa-6" elevation="0" border>
<div class="mb-4 text-h3 font-weight-bold">
{{ tm('announcement.title') }}
</div>
<MarkdownRender
:content="welcomeAnnouncement"
:typewriter="false"
class="welcome-announcement-markdown markdown-content"
/>
</v-card>
</v-col>
</v-row>
</v-container>
<AddNewPlatform v-model:show="showAddPlatformDialog" :metadata="platformMetadata" :config_data="platformConfigData"
@@ -115,12 +144,16 @@ import { computed, ref, watch, onMounted } from 'vue';
import axios from 'axios';
import AddNewPlatform from '@/components/platform/AddNewPlatform.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
import { useModuleI18n } from '@/i18n/composables';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useToast } from '@/utils/toast';
import { MarkdownRender } from 'markstream-vue';
import 'markstream-vue/index.css';
import 'highlight.js/styles/github.css';
type StepState = 'pending' | 'completed' | 'skipped';
const { tm } = useModuleI18n('features/welcome');
const { locale } = useI18n();
const { success: showSuccess, error: showError } = useToast();
const showAddPlatformDialog = ref(false);
@@ -134,6 +167,38 @@ const providerCountBeforeOpen = ref(0);
const platformStepState = ref<StepState>('pending');
const providerStepState = ref<StepState>('pending');
const welcomeAnnouncementRaw = ref<unknown>(null);
function resolveWelcomeAnnouncement(raw: unknown, currentLocale: string) {
if (typeof raw === 'string') {
return raw.trim();
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return '';
}
const localeMap = raw as Record<string, unknown>;
const normalized = currentLocale.replace('-', '_');
const preferredKeys =
normalized.startsWith('zh')
? [normalized, 'zh_CN', 'zh-CN', 'zh', 'en_US', 'en-US', 'en']
: [normalized, 'en_US', 'en-US', 'en', 'zh_CN', 'zh-CN', 'zh'];
for (const key of preferredKeys) {
const value = localeMap[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
}
return '';
}
const welcomeAnnouncement = computed(() =>
resolveWelcomeAnnouncement(welcomeAnnouncementRaw.value, locale.value)
);
const showAnnouncement = computed(() => welcomeAnnouncement.value.length > 0);
const springFestivalDates: Record<number, string> = {
2025: '01-29',
@@ -271,7 +336,19 @@ async function syncDefaultConfigProviderIfNeeded() {
showSuccess(tm('onboard.providerDefaultUpdated', { id: targetProviderId }));
}
async function loadWelcomeAnnouncement() {
try {
const res = await axios.get('https://cloud.astrbot.app/api/v1/announcement');
welcomeAnnouncementRaw.value = res?.data?.data?.notice?.welcome_page ?? null;
} catch (e) {
welcomeAnnouncementRaw.value = null;
console.error(e);
}
}
onMounted(async () => {
await loadWelcomeAnnouncement();
try {
await loadPlatformConfigBase();
if ((platformConfigData.value.platform || []).length > 0) {
@@ -349,4 +426,8 @@ watch(showProviderDialog, async (visible, wasVisible) => {
.welcome-card {
border-radius: 16px;
}
.welcome-announcement-markdown {
line-height: 1.7;
}
</style>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "astrbot-desktop",
"version": "4.15.0",
"version": "4.17.5",
"description": "AstrBot desktop wrapper",
"private": true,
"main": "main.js",
+16
View File
@@ -16,6 +16,8 @@ const kbStopwordsSrc = path.join(
'hit_stopwords.txt',
);
const kbStopwordsDest = 'astrbot/core/knowledge_base/retrieval';
const builtinStarsSrc = path.join(rootDir, 'astrbot', 'builtin_stars');
const builtinStarsDest = 'astrbot/builtin_stars';
const args = [
'run',
@@ -33,11 +35,25 @@ const args = [
'aiosqlite',
'--collect-all',
'pip',
'--collect-all',
'bs4',
'--collect-all',
'readability',
'--collect-all',
'lxml',
'--collect-all',
'lxml_html_clean',
'--collect-all',
'rfc3987_syntax',
'--collect-submodules',
'astrbot.api',
'--collect-submodules',
'astrbot.builtin_stars',
'--collect-data',
'certifi',
'--add-data',
`${builtinStarsSrc}${dataSeparator}${builtinStarsDest}`,
'--add-data',
`${kbStopwordsSrc}${dataSeparator}${kbStopwordsDest}`,
'--distpath',
outputDir,
+93
View File
@@ -0,0 +1,93 @@
# 黑盒语音机器人帮助文档
codex resume 019c57d5-3b44-7a50-a514-1b1b3f0a4448
## Docs
- [教程](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5031038m0.md):
- [开发者服务协议](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5083727m0.md):
- [使用交流](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/4778396m0.md):
- [更新日志](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5029501m0.md):
- [开发计划](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5029504m0.md):
- [基础框架须知](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/7279187m0.md):
- 资源 [请求速率限制](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5192003m0.md):
- 资源 [Websocket](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5029558m0.md):
- 资源 [Bot命令](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5030757m0.md):
- HTTP接口 > 消息接口 [发送消息接口的参数](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5112305m0.md):
- HTTP接口 > 消息接口 [发送消息接口的返回值](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5156437m0.md):
- HTTP接口 > 消息接口 [发送图片形式的频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5088949m0.md):
- HTTP接口 > 消息接口 [发送Markdown文档](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5324071m0.md):
- HTTP接口 > 消息接口 [更新指定频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5274453m0.md):
- HTTP接口 > 消息接口 [删除指定的频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5274471m0.md):
- HTTP接口 > 消息接口 [对某条频道消息增加/取消回应(小表情)](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5274495m0.md):
- HTTP接口 > 消息接口 [发送卡片消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5430115m0.md):
- HTTP接口 > 消息接口 [给用户发送私聊消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5722305m0.md):
- HTTP接口 > 媒体文件上传 [上传媒体文件的参数解析](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5156807m0.md):
- HTTP接口 > 房间角色接口 [权限相关说明](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/4781009m0.md):
- HTTP接口 > 房间角色接口 [接口说明](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5274618m0.md):
- HTTP接口 > 房间表情 [房间表情包](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5252750m0.md):
- HTTP接口 > 房间接口 [房间相关接口文档](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5650569m0.md):
- HTTP接口 > 在线媒体流 [在线媒体流说明文档](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/7020148m0.md):
- HTTP接口 > OAuth [OAuth使用说明](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/7145802m0.md):
- 服务端推送事件 [事件说明](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5243813m0.md):
- 服务端推送事件 [通用推送字段](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5254214m0.md):
- 服务端推送事件 > 机器人命令 [用户使用Bot命令](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5116164m0.md):
- 服务端推送事件 > 频道消息事件 [频道消息事件](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5243816m0.md):
- 服务端推送事件 > 房间消息事件 [房间消息事件](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5254078m0.md):
- 自定义卡片消息 [自定义卡片编辑器](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997428m0.md):
- 自定义卡片消息 > 物料组件 [卡片](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997517m0.md):
- 自定义卡片消息 > 物料组件 [文本](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997518m0.md):
- 自定义卡片消息 > 物料组件 [标题](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997729m0.md):
- 自定义卡片消息 > 物料组件 [图片](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997730m0.md):
- 自定义卡片消息 > 物料组件 [按钮组](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997731m0.md):
- 自定义卡片消息 > 物料组件 [分割线](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997733m0.md):
- 自定义卡片消息 > 物料组件 [倒计时](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997735m0.md):
## API Docs
- WEBSOCKET 连接请求 [连接到黑盒语音服务](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/3545540w0.md):
- HTTP接口 > 消息接口 [发送频道图片消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/196181766e0.md):
- HTTP接口 > 消息接口 [发送频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/195916005e0.md):
- HTTP接口 > 消息接口 [发送卡片消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/231244234e0.md):
- HTTP接口 > 消息接口 [发送频道消息@全体成员/@在线成员](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/196225350e0.md):
- HTTP接口 > 消息接口 [更新指定的频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221115476e0.md):
- HTTP接口 > 消息接口 [删除指定的频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221117785e0.md):
- HTTP接口 > 消息接口 [对某条频道消息增加/取消回应(小表情)](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220985915e0.md):
- HTTP接口 > 消息接口 [给用户发送私聊消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/247164510e0.md):
- HTTP接口 > 媒体文件上传 [上传媒体文件](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/196172729e0.md):
- HTTP接口 > 房间角色接口 [获取房间角色列表](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220721816e0.md):
- HTTP接口 > 房间角色接口 [创建角色](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220860098e0.md):
- HTTP接口 > 房间角色接口 [更新角色](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220893910e0.md):
- HTTP接口 > 房间角色接口 [删除角色](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220864876e0.md):
- HTTP接口 > 房间角色接口 [对指定用户授予指定权限](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/195925401e0.md):
- HTTP接口 > 房间角色接口 [对指定用户剥夺指定权限](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/195927164e0.md):
- HTTP接口 > 房间表情 [获取房间上传的表情包](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221092473e0.md):
- HTTP接口 > 房间表情 [房间删除表情包](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221112168e0.md):
- HTTP接口 > 房间表情 [房间更新表情包名称](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221346019e0.md):
- HTTP接口 > 房间接口 [修改房间内昵称](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373089e0.md):
- HTTP接口 > 房间接口 [分页获取加入的房间列表](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373523e0.md):
- HTTP接口 > 房间接口 [获取房间信息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373528e0.md):
- HTTP接口 > 房间接口 [退出房间](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373638e0.md):
- HTTP接口 > 房间接口 [房间踢人](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373709e0.md):
- HTTP接口 > 房间接口 [语音频道之间移动用户](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/318260744e0.md):
- HTTP接口 > 房间接口 [踢出语音频道中的用户](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/318266039e0.md):
- HTTP接口 > 房间接口 [禁言/解禁用户](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325086722e0.md):
- HTTP接口 > 房间接口 [频道内麦克风静音/解禁](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325092125e0.md): 对未静音对象调用时对其静音;对静音对象调用时解除静音
- HTTP接口 > 房间接口 [房间内麦克风静音/解禁](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325104333e0.md):
- HTTP接口 > 房间接口 [房间内扬声器静音/解禁](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325105640e0.md):
- HTTP接口 > 房间接口 [获取用户所在频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325187362e0.md): bot需要在查询的房间中
- HTTP接口 > 房间接口 [获取语音频道内在线成员列表](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325207647e0.md):
- HTTP接口 > 房间接口 [创建频道邀请链接](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325223584e0.md): 需要 创建邀请 权限
- HTTP接口 > 房间接口 [频道设置修改](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325259753e0.md): 需要 编辑频道 权限
- HTTP接口 > 房间接口 [频道名编辑](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325264530e0.md): 需要 编辑频道 权限
- HTTP接口 > 房间接口 [设置频道密码](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325512688e0.md):
- HTTP接口 > 房间接口 [修改权限组或成员权限](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325672775e0.md): # 服务器权限管理文档
- HTTP接口 > 房间接口 [获取房间用户列表](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/326508787e0.md):
- HTTP接口 > 房间接口 [获取用户频道权限](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/339765173e0.md):
- HTTP接口 > 房间接口 [创建频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/340658298e0.md): 需要 管理频道(1<<2) 权限
- HTTP接口 > 房间接口 [删除频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/340660409e0.md): 需要 管理频道(1<<2) 权限
- HTTP接口 > 在线媒体流 [推流至语音频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/320947489e0.md):
- HTTP接口 > 在线媒体流 [停止推流至语音频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/320947513e0.md):
- HTTP接口 > OAuth [获取授权码](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/329051392e0.md): 获取授权码链接示例
- HTTP接口 > OAuth [获取AccessToken](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/329070402e0.md):
- HTTP接口 > OAuth [刷新AccessToken](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/329079907e0.md):
- HTTP接口 > OAuth [获取用户信息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/329599863e0.md):
- HTTP接口 > OAuth [获取用户房间内语音时长](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/332236185e0.md): 时间跨度不能超过30天
- HTTP接口 > OAuth [获取用户房间内语音游戏时长](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/332238065e0.md): 时间跨度不能超过30天
- HTTP接口 > OAuth [获取用户信息-自动触发授权](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/331602654e0.md): 在发起api请求时可以携带以下query作为参数 如果没有token且用户在线则会为用户唤起授权弹窗
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.15.0"
version = "4.17.5"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.12"
@@ -17,7 +17,7 @@ dependencies = [
"beautifulsoup4>=4.13.4",
"certifi>=2025.4.26",
"chardet~=5.1.0",
"colorlog>=6.9.0",
"loguru>=0.7.2",
"cryptography>=44.0.3",
"dashscope>=1.23.2",
"defusedxml>=0.7.1",
+2 -2
View File
@@ -10,7 +10,7 @@ apscheduler>=3.11.0
beautifulsoup4>=4.13.4
certifi>=2025.4.26
chardet~=5.1.0
colorlog>=6.9.0
loguru>=0.7.2
cryptography>=44.0.3
dashscope>=1.23.2
defusedxml>=0.7.1
@@ -53,4 +53,4 @@ jieba>=0.42.1
markitdown-no-magika[docx,xls,xlsx]>=0.1.2
xinference-client
tenacity>=9.1.2
shipyard-python-sdk>=0.2.4
shipyard-python-sdk>=0.2.4
+73
View File
@@ -90,6 +90,21 @@ class MockToolExecutor:
return generator()
class MockFailingProvider(MockProvider):
async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
raise RuntimeError("primary provider failed")
class MockErrProvider(MockProvider):
async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
return LLMResponse(
role="err",
completion_text="primary provider returned error",
)
class MockHooks(BaseAgentRunHooks):
"""模拟钩子函数"""
@@ -321,6 +336,64 @@ async def test_hooks_called_with_max_step(
assert mock_hooks.tool_end_called, "on_tool_end应该被调用"
@pytest.mark.asyncio
async def test_fallback_provider_used_when_primary_raises(
runner, provider_request, mock_tool_executor, mock_hooks
):
primary_provider = MockFailingProvider()
fallback_provider = MockProvider()
fallback_provider.should_call_tools = False
await runner.reset(
provider=primary_provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
fallback_providers=[fallback_provider],
)
async for _ in runner.step_until_done(5):
pass
final_resp = runner.get_final_llm_resp()
assert final_resp is not None
assert final_resp.role == "assistant"
assert final_resp.completion_text == "这是我的最终回答"
assert primary_provider.call_count == 1
assert fallback_provider.call_count == 1
@pytest.mark.asyncio
async def test_fallback_provider_used_when_primary_returns_err(
runner, provider_request, mock_tool_executor, mock_hooks
):
primary_provider = MockErrProvider()
fallback_provider = MockProvider()
fallback_provider.should_call_tools = False
await runner.reset(
provider=primary_provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
fallback_providers=[fallback_provider],
)
async for _ in runner.step_until_done(5):
pass
final_resp = runner.get_final_llm_resp()
assert final_resp is not None
assert final_resp.role == "assistant"
assert final_resp.completion_text == "这是我的最终回答"
assert primary_provider.call_count == 1
assert fallback_provider.call_count == 1
if __name__ == "__main__":
# 运行测试
pytest.main([__file__, "-v"])