Compare commits

...

28 Commits

Author SHA1 Message Date
Soulter
6131386893 chore: bump version to 4.23.0 2026-04-13 00:36:04 +08:00
Kangyang Ji
3b2435875c fix: type use of defineStore in @/stores (#7490) 2026-04-12 23:47:40 +08:00
Kangyang Ji
2a229c4beb fix: wrong image name in compose (#7488)
* fix: wrong image name in compose
2026-04-12 22:07:58 +08:00
Soulter
d1913b5950 fix: update tool call icons from mdi-code-braces to mdi-code-json 2026-04-12 22:06:08 +08:00
Soulter
7172281436 feat: add MessageList component and update MDI icon subset 2026-04-12 21:55:10 +08:00
Soulter
bd08273640 refactor: chatui style (#7485) 2026-04-12 20:47:51 +08:00
Soulter
baaad2a69e perf: add 'dashboard_update' to the list of ignored effective commands in HelpCommand 2026-04-12 17:34:57 +08:00
Sascha Buehrle
9a65873424 fix: use UMO-bound config for group_icl_enable in on_message (#7397)
* fix: read group_icl_enable from UMO-bound config (fixes #7305)

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-04-12 16:42:48 +08:00
Soulter
f50f6cd49f refactor: remove rarely-used builtin commands and consolidate functionality (#7478)
* refactor: remove rarely-used builtin commands and consolidate functionality

* docs: update docs

* Update astrbot/builtin_stars/builtin_commands/commands/admin.py

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

* chore: remove /op, /deop

* chore: ruff format

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-12 16:40:08 +08:00
Soulter
5d2b29f8f8 perf: add validation for MCP stdio configuration (#7477)
* perf: add validation for MCP stdio configuration

* Update astrbot/core/agent/mcp_client.py

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

* Update astrbot/core/agent/mcp_client.py

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

* chore: ruff format

* fix: correct regex pattern for shell meta characters

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-12 15:04:46 +08:00
Soulter
68a195e12b perf: make no-new-privileges true when use docker 2026-04-12 14:37:33 +08:00
NekoYukari
2274e0efc9 fix: support both Bailian Rerank API formats based on URL endpoint (#7250)
* fix: support both Bailian Rerank API formats based on URL endpoint

阿里云百炼有两个不同的 rerank API 端点:
- /compatible-api/v1/reranks: 使用扁平请求格式 {model, query, documents}
- /api/v1/services/rerank/...: 需要 input 包装 {model, input: {...}}

之前代码只根据模型名判断格式,导致 qwen3-rerank + compatible-api 组合失败。

修复内容:
- _build_payload(): 根据 URL 是否含 'compatible-api' 决定请求格式
- _parse_results(): 根据 URL 判断响应中 results 的位置

Fixes #7161

* refactor: reduce duplication in bailian rerank payload and results handling

- Extract params building outside the if-else branch
- Add back empty results warning log
- Simplify error handling variable assignment

* fix: simplify bailian rerank payload to use model-based logic only

qwen3-rerank always uses flat format regardless of API endpoint.
Other models (gte-rerank-v2, etc.) use input wrapper format.

This simplifies the logic and correctly handles all model/URL combinations.
Tested: qwen3-rerank accepts both formats, gte-rerank-v2 only supports input wrapper.

---------

Co-authored-by: root <root@localhost.localdomain>
Co-authored-by: Fix Bot <fix@example.com>
2026-04-12 14:23:15 +08:00
Sagiri777
f1f1720c58 feat(weixin_oc): support reply components (#7380)
* feat(weixin_oc): support reply parsing

* fix: harden weixin oc reply parsing

* fix: correct weixin oc reply cache matching

* fix: harden weixin oc reply parsing

* chore: remove test

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-04-12 14:22:07 +08:00
yuanqiuye
6691411550 fix(discord): prevent 10062 Unknown interaction error by deferring slash commands immediately (#7474)
* fix(discord): prevent 10062 Unknown interaction error by deferring slash commands immediately

* fix(discord): early return if deferral fails

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

* chore: ruff format

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-04-12 14:12:07 +08:00
Soulter
8d28693e32 fix: ensure JSON response is encoded with non-ASCII characters for shell command execution (#7475)
fixes: #7452
2026-04-12 14:01:20 +08:00
Soulter
5f95bbc422 fix: Update plugin version check logic to support pre-releases
fixes: #7473
2026-04-12 13:59:16 +08:00
時壹
a7ce8df024 feat: implement retry mechanism for QQ Official API file uploads (#7430)
* feat: implement retry mechanism for QQ Official API file uploads

* fix: update error logging message for media file upload retries
2026-04-12 13:47:44 +08:00
Misaka Mikoto
09848956e2 fix: align function tool module path with plugin main module (#7462) 2026-04-12 13:44:05 +08:00
Shujakuin
f5207d840c fix: telegram polling recovery after network failures (#7468) 2026-04-12 13:42:51 +08:00
Soulter
b801003801 chore: bump version to 4.23.0-beta.1 2026-04-11 21:15:30 +08:00
Soulter
2472a12671 feat: filesystem grep, read, write, edit file and workspace support (#7402)
* feat: filesystem grep, read, edit file

* feat: add file write tool and enhance file read functionality

* feat: enhance tool prompt formatting and add escaped text decoding for file editing

* feat: remove redundant safe path tests from security restrictions

* feat: implement file read tool with support for text and image files, including validation for large files

* feat: add file read utilities and integrate with filesystem tools

* refactor: move computer tools to builtin tools registry

* refactor: remove unused plugin_context parameter from _apply_sandbox_tools

* feat: supports to display enabled builtin tools in configs

* feat: add tooltip for disabled builtin tools and update localization strings

* feat: add workspace extra prompt handling in message processing

* feat: add ripgrep installation to Dockerfile

* perf: shell executed in workspace dir in local env

* feat: enhance file reading capabilities to support PDF and DOCX parsing, including workspace storage for long documents

* feat: update converted text notice to suggest using grep for large files

* feat: implement handling for large tool results with overflow file writing and read tool integration

* fix: test

* feat: enhance onboarding steps to include computer access configuration and related help information

* feat: add support for additional temporary path in restricted environment checks

* feat: update computer access hints and add detailed configuration instructions
2026-04-11 17:01:54 +08:00
MinaraAgent
b8ccfe3f64 feat(discord): add configurable bot message filtering, allow bot to receive other bots' messages (#6505)
* feat(discord): add configurable bot message filtering

Add `discord_allow_bot_messages` config option to allow receiving
messages from other Discord bots. This is useful for bot-to-bot
communication scenarios like message forwarding between channels.

By default, bot messages are still ignored (backward compatible).

Usage: Set `discord_allow_bot_messages: true` in your Discord
platform configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(discord): add WebUI config for discord_allow_bot_messages

Add configuration option to the dashboard for the new
discord_allow_bot_messages feature. Users can now enable/disable
this option through the WebUI in all supported languages
(zh-CN, en-US, ru-RU).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(discord): use typed constructor argument for allow_bot_messages

Address code review feedback:
- Add `allow_bot_messages` as a typed constructor argument in DiscordBotClient
- Simplify the on_message check by using the instance attribute directly
- Pass the parameter in constructor instead of using setattr

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: MinaraAgent <minara-agent@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 01:27:46 +08:00
2doright
574e5089ba docs: fix path concatenation error in storage.md (#7448)
* docs: 修复 storage.md 中路径拼接的错误示例

get_astrbot_data_path() 返回的是 str 类型,直接使用 / 运算符会导致 TypeError。修改文档示例,添加 Path() 包裹

* docs: 修复 storage.md 中路径拼接的错误示例

get_astrbot_data_path() 返回的是 str 类型,直接使用 / 运算符会导致 TypeError。修改文档示例,添加 Path() 包裹。
2026-04-10 18:41:54 +08:00
Soulter
16f57dd971 chore: remove lxml and bs4 deps (#7449) 2026-04-10 18:39:54 +08:00
エイカク
122e6c719f fix: make desktop plugin dependency loading safer on Windows (#7446)
* fix: make desktop plugin dependency loading safer on Windows

* fix: restore dependency recovery after precheck fallback

* test: cover version mismatch reinstall path

* refactor: clarify dependency recovery state handling

* style: format star manager with ruff

* fix: skip dependency recovery for plugin import errors

* fix: surface unexpected dependency recovery failures
2026-04-10 17:08:10 +09:00
Shujakuin
9c14a50b06 fix: split long telegram final segments (#7432)
* fix: split long telegram final segments

* test: refine telegram adapter helpers

* Update astrbot/core/platform/sources/telegram/tg_event.py

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

* Update tg_event.py

* chore: ruff format

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-04-10 14:36:47 +08:00
Soulter
c791c815e1 perf: merge 3 cron tools into 1 cron manage tool, and add edit capability for cron tool. (#7445)
* perf: replace cron tools with FutureTaskTool for improved task management

* feat: enhance FutureTaskTool with edit functionality and improve descriptions

* feat: add edit functionality for cron jobs and update related UI components
2026-04-10 14:32:57 +08:00
Soulter
e34d9504e4 chore: update logo in README.md 2026-04-09 17:27:09 +08:00
136 changed files with 14276 additions and 9270 deletions

View File

@@ -16,6 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
gnupg \
git \
ripgrep \
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean \

View File

@@ -1,4 +1,5 @@
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
![astrbot-github-banner-v2-light-0405_副本](https://github.com/user-attachments/assets/36fb04e4-cc75-4454-bd8b-049d11aa86f9)
<div align="center">

View File

@@ -36,9 +36,9 @@ class Main(star.Star):
if self.ltm_enabled(event) and self.ltm and has_image_or_plain:
need_active = await self.ltm.need_active_reply(event)
group_icl_enable = self.context.get_config()["provider_ltm_settings"][
"group_icl_enable"
]
group_icl_enable = self.context.get_config(umo=event.unified_msg_origin)[
"provider_ltm_settings"
]["group_icl_enable"]
if group_icl_enable:
"""记录对话"""
try:

View File

@@ -1,29 +1,15 @@
# Commands module
from .admin import AdminCommands
from .alter_cmd import AlterCmdCommands
from .conversation import ConversationCommands
from .help import HelpCommand
from .llm import LLMCommands
from .persona import PersonaCommands
from .plugin import PluginCommands
from .provider import ProviderCommands
from .setunset import SetUnsetCommands
from .sid import SIDCommand
from .t2i import T2ICommand
from .tts import TTSCommand
__all__ = [
"AdminCommands",
"AlterCmdCommands",
"ConversationCommands",
"HelpCommand",
"LLMCommands",
"PersonaCommands",
"PluginCommands",
"ProviderCommands",
"SIDCommand",
"SetUnsetCommands",
"T2ICommand",
"TTSCommand",
"SIDCommand",
]

View File

@@ -1,5 +1,5 @@
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageChain, MessageEventResult
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.core.config.default import VERSION
from astrbot.core.utils.io import download_dashboard
@@ -8,70 +8,8 @@ class AdminCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
async def op(self, event: AstrMessageEvent, admin_id: str = "") -> None:
"""授权管理员。op <admin_id>"""
if not admin_id:
event.set_result(
MessageEventResult().message(
"使用方法: /op <id> 授权管理员;/deop <id> 取消管理员。可通过 /sid 获取 ID。",
),
)
return
self.context.get_config()["admins_id"].append(str(admin_id))
self.context.get_config().save_config()
event.set_result(MessageEventResult().message("授权成功。"))
async def deop(self, event: AstrMessageEvent, admin_id: str = "") -> None:
"""取消授权管理员。deop <admin_id>"""
if not admin_id:
event.set_result(
MessageEventResult().message(
"使用方法: /deop <id> 取消管理员。可通过 /sid 获取 ID。",
),
)
return
try:
self.context.get_config()["admins_id"].remove(str(admin_id))
self.context.get_config().save_config()
event.set_result(MessageEventResult().message("取消授权成功。"))
except ValueError:
event.set_result(
MessageEventResult().message("此用户 ID 不在管理员名单内。"),
)
async def wl(self, event: AstrMessageEvent, sid: str = "") -> None:
"""添加白名单。wl <sid>"""
if not sid:
event.set_result(
MessageEventResult().message(
"使用方法: /wl <id> 添加白名单;/dwl <id> 删除白名单。可通过 /sid 获取 ID。",
),
)
return
cfg = self.context.get_config(umo=event.unified_msg_origin)
cfg["platform_settings"]["id_whitelist"].append(str(sid))
cfg.save_config()
event.set_result(MessageEventResult().message("添加白名单成功。"))
async def dwl(self, event: AstrMessageEvent, sid: str = "") -> None:
"""删除白名单。dwl <sid>"""
if not sid:
event.set_result(
MessageEventResult().message(
"使用方法: /dwl <id> 删除白名单。可通过 /sid 获取 ID。",
),
)
return
try:
cfg = self.context.get_config(umo=event.unified_msg_origin)
cfg["platform_settings"]["id_whitelist"].remove(str(sid))
cfg.save_config()
event.set_result(MessageEventResult().message("删除白名单成功。"))
except ValueError:
event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
async def update_dashboard(self, event: AstrMessageEvent) -> None:
"""更新管理面板"""
await event.send(MessageChain().message("正在尝试更新管理面板..."))
await event.send(MessageChain().message("⏳ Updating dashboard..."))
await download_dashboard(version=f"v{VERSION}", latest=False)
await event.send(MessageChain().message("管理面板更新完成。"))
await event.send(MessageChain().message("✅ Dashboard updated successfully."))

View File

@@ -1,173 +0,0 @@
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionTypeFilter
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
from astrbot.core.utils.command_parser import CommandParserMixin
from .utils.rst_scene import RstScene
class AlterCmdCommands(CommandParserMixin):
def __init__(self, context: star.Context) -> None:
self.context = context
async def update_reset_permission(self, scene_key: str, perm_type: str) -> None:
"""更新reset命令在特定场景下的权限设置"""
from astrbot.api import sp
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
plugin_cfg = alter_cmd_cfg.get("astrbot", {})
reset_cfg = plugin_cfg.get("reset", {})
reset_cfg[scene_key] = perm_type
plugin_cfg["reset"] = reset_cfg
alter_cmd_cfg["astrbot"] = plugin_cfg
await sp.global_put("alter_cmd", alter_cmd_cfg)
async def alter_cmd(self, event: AstrMessageEvent) -> None:
token = self.parse_commands(event.message_str)
if token.len < 3:
await event.send(
MessageChain().message(
"该指令用于设置指令或指令组的权限。\n"
"格式: /alter_cmd <cmd_name> <admin/member>\n"
"例1: /alter_cmd c1 admin 将 c1 设为管理员指令\n"
"例2: /alter_cmd g1 c1 admin 将 g1 指令组的 c1 子指令设为管理员指令\n"
"/alter_cmd reset config 打开 reset 权限配置",
),
)
return
# 兼容 reset scene 的专门配置
cmd_name = token.get(1)
cmd_type = token.get(2)
if cmd_name == "reset" and cmd_type == "config":
from astrbot.api import sp
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
plugin_ = alter_cmd_cfg.get("astrbot", {})
reset_cfg = plugin_.get("reset", {})
group_unique_on = reset_cfg.get("group_unique_on", "admin")
group_unique_off = reset_cfg.get("group_unique_off", "admin")
private = reset_cfg.get("private", "member")
config_menu = f"""reset命令权限细粒度配置
当前配置:
1. 群聊+会话隔离开: {group_unique_on}
2. 群聊+会话隔离关: {group_unique_off}
3. 私聊: {private}
修改指令格式:
/alter_cmd reset scene <场景编号> <admin/member>
例如: /alter_cmd reset scene 2 member"""
await event.send(MessageChain().message(config_menu))
return
if cmd_name == "reset" and cmd_type == "scene" and token.len >= 4:
scene_num = token.get(3)
perm_type = token.get(4)
if scene_num is None or perm_type is None:
await event.send(MessageChain().message("场景编号和权限类型不能为空"))
return
if not scene_num.isdigit() or int(scene_num) < 1 or int(scene_num) > 3:
await event.send(
MessageChain().message("场景编号必须是 1-3 之间的数字"),
)
return
if perm_type not in ["admin", "member"]:
await event.send(
MessageChain().message("权限类型错误,只能是 admin 或 member"),
)
return
scene_num = int(scene_num)
scene = RstScene.from_index(scene_num)
scene_key = scene.key
await self.update_reset_permission(scene_key, perm_type)
await event.send(
MessageChain().message(
f"已将 reset 命令在{scene.name}场景下的权限设为{perm_type}",
),
)
return
if cmd_type not in ["admin", "member"]:
await event.send(
MessageChain().message("指令类型错误,可选类型有 admin, member"),
)
return
# 查找指令
cmd_name = " ".join(token.tokens[1:-1])
cmd_type = token.get(-1)
found_command = None
cmd_group = False
for handler in star_handlers_registry:
assert isinstance(handler, StarHandlerMetadata)
for filter_ in handler.event_filters:
if isinstance(filter_, CommandFilter):
if filter_.equals(cmd_name):
found_command = handler
break
elif isinstance(filter_, CommandGroupFilter):
if filter_.equals(cmd_name):
found_command = handler
cmd_group = True
break
if not found_command:
await event.send(MessageChain().message("未找到该指令"))
return
found_plugin = star_map[found_command.handler_module_path]
from astrbot.api import sp
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
plugin_ = alter_cmd_cfg.get(found_plugin.name, {})
cfg = plugin_.get(found_command.handler_name, {})
cfg["permission"] = cmd_type
plugin_[found_command.handler_name] = cfg
alter_cmd_cfg[found_plugin.name] = plugin_
await sp.global_put("alter_cmd", alter_cmd_cfg)
# 注入权限过滤器
found_permission_filter = False
for filter_ in found_command.event_filters:
if isinstance(filter_, PermissionTypeFilter):
if cmd_type == "admin":
from astrbot.api.event import filter
filter_.permission_type = filter.PermissionType.ADMIN
else:
from astrbot.api.event import filter
filter_.permission_type = filter.PermissionType.MEMBER
found_permission_filter = True
break
if not found_permission_filter:
from astrbot.api.event import filter
found_command.event_filters.insert(
0,
PermissionTypeFilter(
filter.PermissionType.ADMIN
if cmd_type == "admin"
else filter.PermissionType.MEMBER,
),
)
cmd_group_str = "指令组" if cmd_group else "指令"
await event.send(
MessageChain().message(
f"已将「{cmd_name}{cmd_group_str} 的权限级别调整为 {cmd_type}",
),
)

View File

@@ -1,13 +1,9 @@
import datetime
from astrbot.api import sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.agent.runners.deerflow.constants import (
DEERFLOW_PROVIDER_TYPE,
DEERFLOW_THREAD_ID_KEY,
)
from astrbot.core.platform.astr_message_event import MessageSession
from astrbot.core.platform.message_type import MessageType
from astrbot.core.utils.active_event_registry import active_event_registry
from .utils.rst_scene import RstScene
@@ -60,8 +56,8 @@ class ConversationCommands:
if required_perm == "admin" and message.role != "admin":
message.set_result(
MessageEventResult().message(
f"{scene.name}场景下reset命令需要管理员权限"
f" (ID {message.get_sender_id()}) 不是管理员,无法执行此操作。",
f"Reset command requires admin permission in {scene.name} scenario, "
f"you (ID {message.get_sender_id()}) are not admin, cannot perform this action.",
),
)
return
@@ -74,12 +70,16 @@ class ConversationCommands:
scope_id=umo,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
)
message.set_result(MessageEventResult().message("重置对话成功。"))
message.set_result(
MessageEventResult().message("✅ Conversation reset successfully.")
)
return
if not self.context.get_using_provider(umo):
message.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
MessageEventResult().message(
"😕 Cannot find any LLM provider. Configure one first."
),
)
return
@@ -88,7 +88,7 @@ class ConversationCommands:
if not cid:
message.set_result(
MessageEventResult().message(
"当前未处于对话状态,请 /switch 切换或者 /new 创建。",
"😕 You are not in a conversation. Use /new to create one.",
),
)
return
@@ -101,7 +101,7 @@ class ConversationCommands:
[],
)
ret = "清除聊天历史成功!"
ret = "✅ Conversation reset successfully."
message.set_extra("_clean_ltm_session", True)
@@ -124,148 +124,15 @@ class ConversationCommands:
if stopped_count > 0:
message.set_result(
MessageEventResult().message(
f"已请求停止 {stopped_count} 个运行中的任务。"
f"✅ Requested to stop {stopped_count} running tasks."
)
)
return
message.set_result(MessageEventResult().message("当前会话没有运行中的任务。"))
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
"""查看对话记录"""
if not self.context.get_using_provider(message.unified_msg_origin):
message.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
size_per_page = 6
conv_mgr = self.context.conversation_manager
umo = message.unified_msg_origin
session_curr_cid = await conv_mgr.get_curr_conversation_id(umo)
if not session_curr_cid:
session_curr_cid = await conv_mgr.new_conversation(
umo,
message.get_platform_id(),
)
contexts, total_pages = await conv_mgr.get_human_readable_context(
umo,
session_curr_cid,
page,
size_per_page,
message.set_result(
MessageEventResult().message("✅ No running tasks in the current session.")
)
parts = []
for context in contexts:
if len(context) > 150:
context = context[:150] + "..."
parts.append(f"{context}\n")
history = "".join(parts)
ret = (
f"当前对话历史记录:"
f"{history or '无历史记录'}\n\n"
f"{page} 页 | 共 {total_pages}\n"
f"*输入 /history 2 跳转到第 2 页"
)
message.set_result(MessageEventResult().message(ret).use_t2i(False))
async def convs(self, message: AstrMessageEvent, page: int = 1) -> None:
"""查看对话列表"""
cfg = self.context.get_config(umo=message.unified_msg_origin)
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
message.set_result(
MessageEventResult().message(
f"{THIRD_PARTY_AGENT_RUNNER_STR} 对话列表功能暂不支持。",
),
)
return
size_per_page = 6
"""获取所有对话列表"""
conversations_all = await self.context.conversation_manager.get_conversations(
message.unified_msg_origin,
)
"""计算总页数"""
total_pages = (len(conversations_all) + size_per_page - 1) // size_per_page
"""确保页码有效"""
page = max(1, min(page, total_pages))
"""分页处理"""
start_idx = (page - 1) * size_per_page
end_idx = start_idx + size_per_page
conversations_paged = conversations_all[start_idx:end_idx]
parts = ["对话列表:\n---\n"]
"""全局序号从当前页的第一个开始"""
global_index = start_idx + 1
"""生成所有对话的标题字典"""
_titles = {}
for conv in conversations_all:
title = conv.title if conv.title else "新对话"
_titles[conv.cid] = title
"""遍历分页后的对话生成列表显示"""
provider_settings = cfg.get("provider_settings", {})
platform_name = message.get_platform_name()
for conv in conversations_paged:
(
persona_id,
_,
force_applied_persona_id,
_,
) = await self.context.persona_manager.resolve_selected_persona(
umo=message.unified_msg_origin,
conversation_persona_id=conv.persona_id,
platform_name=platform_name,
provider_settings=provider_settings,
)
if persona_id == "[%None]":
persona_name = ""
elif persona_id:
persona_name = persona_id
else:
persona_name = ""
if force_applied_persona_id:
persona_name = f"{persona_name} (自定义规则)"
title = _titles.get(conv.cid, "新对话")
parts.append(
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
)
global_index += 1
parts.append("---\n")
ret = "".join(parts)
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
message.unified_msg_origin,
)
if curr_cid:
"""从所有对话的标题字典中获取标题"""
title = _titles.get(curr_cid, "新对话")
ret += f"\n当前对话: {title}({curr_cid[:4]})"
else:
ret += "\n当前对话: 无"
cfg = self.context.get_config(umo=message.unified_msg_origin)
unique_session = cfg["platform_settings"]["unique_session"]
if unique_session:
ret += "\n会话隔离粒度: 个人"
else:
ret += "\n会话隔离粒度: 群聊"
ret += f"\n{page} 页 | 共 {total_pages}"
ret += "\n*输入 /ls 2 跳转到第 2 页"
message.set_result(MessageEventResult().message(ret).use_t2i(False))
return
async def new_conv(self, message: AstrMessageEvent) -> None:
"""创建新对话"""
cfg = self.context.get_config(umo=message.unified_msg_origin)
@@ -277,7 +144,9 @@ class ConversationCommands:
scope_id=message.unified_msg_origin,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
)
message.set_result(MessageEventResult().message("已创建新对话。"))
message.set_result(
MessageEventResult().message("✅ New conversation created.")
)
return
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
@@ -291,130 +160,7 @@ class ConversationCommands:
message.set_extra("_clean_ltm_session", True)
message.set_result(
MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"),
MessageEventResult().message(
f"✅ Switched to new conversation: {cid[:4]}"
),
)
async def groupnew_conv(self, message: AstrMessageEvent, sid: str = "") -> None:
"""创建新群聊对话"""
if sid:
session = str(
MessageSession(
platform_name=message.platform_meta.id,
message_type=MessageType("GroupMessage"),
session_id=sid,
),
)
cpersona = await self._get_current_persona_id(session)
cid = await self.context.conversation_manager.new_conversation(
session,
message.get_platform_id(),
persona_id=cpersona,
)
message.set_result(
MessageEventResult().message(
f"群聊 {session} 已切换到新对话: 新对话({cid[:4]})。",
),
)
else:
message.set_result(
MessageEventResult().message("请输入群聊 ID。/groupnew 群聊ID。"),
)
async def switch_conv(
self,
message: AstrMessageEvent,
index: int | None = None,
) -> None:
"""通过 /ls 前面的序号切换对话"""
if not isinstance(index, int):
message.set_result(
MessageEventResult().message("类型错误,请输入数字对话序号。"),
)
return
if index is None:
message.set_result(
MessageEventResult().message(
"请输入对话序号。/switch 对话序号。/ls 查看对话 /new 新建对话",
),
)
return
conversations = await self.context.conversation_manager.get_conversations(
message.unified_msg_origin,
)
if index > len(conversations) or index < 1:
message.set_result(
MessageEventResult().message("对话序号错误,请使用 /ls 查看"),
)
else:
conversation = conversations[index - 1]
title = conversation.title if conversation.title else "新对话"
await self.context.conversation_manager.switch_conversation(
message.unified_msg_origin,
conversation.cid,
)
message.set_result(
MessageEventResult().message(
f"切换到对话: {title}({conversation.cid[:4]})。",
),
)
async def rename_conv(self, message: AstrMessageEvent, new_name: str = "") -> None:
"""重命名对话"""
if not new_name:
message.set_result(MessageEventResult().message("请输入新的对话名称。"))
return
await self.context.conversation_manager.update_conversation_title(
message.unified_msg_origin,
new_name,
)
message.set_result(MessageEventResult().message("重命名对话成功。"))
async def del_conv(self, message: AstrMessageEvent) -> None:
"""删除当前对话"""
umo = message.unified_msg_origin
cfg = self.context.get_config(umo=umo)
is_unique_session = cfg["platform_settings"]["unique_session"]
if message.get_group_id() and not is_unique_session and message.role != "admin":
# 群聊,没开独立会话,发送人不是管理员
message.set_result(
MessageEventResult().message(
f"会话处于群聊,并且未开启独立会话,并且您 (ID {message.get_sender_id()}) 不是管理员,因此没有权限删除当前对话。",
),
)
return
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(umo, exclude=message)
await sp.remove_async(
scope="umo",
scope_id=umo,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
)
message.set_result(MessageEventResult().message("重置对话成功。"))
return
session_curr_cid = (
await self.context.conversation_manager.get_curr_conversation_id(umo)
)
if not session_curr_cid:
message.set_result(
MessageEventResult().message(
"当前未处于对话状态,请 /switch 序号 切换或 /new 创建。",
),
)
return
active_event_registry.stop_all(umo, exclude=message)
await self.context.conversation_manager.delete_conversation(
umo,
session_curr_cid,
)
ret = "删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"
message.set_extra("_clean_ltm_session", True)
message.set_result(MessageEventResult().message(ret))

View File

@@ -32,7 +32,6 @@ class HelpCommand:
return []
lines: list[str] = []
hidden_commands = {"set", "unset", "websearch"}
def walk(items: list[dict], indent: int = 0) -> None:
for item in items:
@@ -49,9 +48,12 @@ class HelpCommand:
or item.get("original_command")
or item.get("handler_name")
)
if not effective:
continue
if effective in hidden_commands:
if not effective or effective in [
"set",
"unset",
"help",
"dashboard_update",
]:
continue
description = item.get("description") or ""
@@ -73,12 +75,13 @@ class HelpCommand:
dashboard_version = await get_dashboard_version()
command_lines = await self._build_reserved_command_lines()
commands_section = (
"\n".join(command_lines) if command_lines else "暂无启用的内置指令"
"\n".join(command_lines)
if command_lines
else "No enabled built-in commands."
)
msg_parts = [
f"AstrBot v{VERSION}(WebUI: {dashboard_version})",
"内置指令:",
commands_section,
]
if notice:

View File

@@ -1,20 +0,0 @@
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageChain
class LLMCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
async def llm(self, event: AstrMessageEvent) -> None:
"""开启/关闭 LLM"""
cfg = self.context.get_config(umo=event.unified_msg_origin)
enable = cfg["provider_settings"].get("enable", True)
if enable:
cfg["provider_settings"]["enable"] = False
status = "关闭"
else:
cfg["provider_settings"]["enable"] = True
status = "开启"
cfg.save_config()
await event.send(MessageChain().message(f"{status} LLM 聊天功能。"))

View File

@@ -1,216 +0,0 @@
import builtins
from typing import TYPE_CHECKING
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
if TYPE_CHECKING:
from astrbot.core.db.po import Persona
class PersonaCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
def _build_tree_output(
self,
folder_tree: list[dict],
all_personas: list["Persona"],
depth: int = 0,
) -> list[str]:
"""递归构建树状输出,使用短线条表示层级"""
lines: list[str] = []
# 使用短线条作为缩进前缀,每层只用 "│" 加一个空格
prefix = "" * depth
for folder in folder_tree:
# 输出文件夹
lines.append(f"{prefix}├ 📁 {folder['name']}/")
# 获取该文件夹下的人格
folder_personas = [
p for p in all_personas if p.folder_id == folder["folder_id"]
]
child_prefix = "" * (depth + 1)
# 输出该文件夹下的人格
for persona in folder_personas:
lines.append(f"{child_prefix}├ 👤 {persona.persona_id}")
# 递归处理子文件夹
children = folder.get("children", [])
if children:
lines.extend(
self._build_tree_output(
children,
all_personas,
depth + 1,
)
)
return lines
async def persona(self, message: AstrMessageEvent) -> None:
l = message.message_str.split(" ") # noqa: E741
umo = message.unified_msg_origin
curr_persona_name = ""
cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
default_persona = await self.context.persona_manager.get_default_persona_v3(
umo=umo,
)
force_applied_persona_id = None
curr_cid_title = ""
if cid:
conv = await self.context.conversation_manager.get_conversation(
unified_msg_origin=umo,
conversation_id=cid,
create_if_not_exists=True,
)
if conv is None:
message.set_result(
MessageEventResult().message(
"当前对话不存在,请先使用 /new 新建一个对话。",
),
)
return
provider_settings = self.context.get_config(umo=umo).get(
"provider_settings",
{},
)
(
persona_id,
_,
force_applied_persona_id,
_,
) = await self.context.persona_manager.resolve_selected_persona(
umo=umo,
conversation_persona_id=conv.persona_id,
platform_name=message.get_platform_name(),
provider_settings=provider_settings,
)
if persona_id == "[%None]":
curr_persona_name = ""
elif persona_id:
curr_persona_name = persona_id
if force_applied_persona_id:
curr_persona_name = f"{curr_persona_name} (自定义规则)"
curr_cid_title = conv.title if conv.title else "新对话"
curr_cid_title += f"({cid[:4]})"
if len(l) == 1:
message.set_result(
MessageEventResult()
.message(
f"""[Persona]
- 人格情景列表: `/persona list`
- 设置人格情景: `/persona 人格`
- 人格情景详细信息: `/persona view 人格`
- 取消人格: `/persona unset`
默认人格情景: {default_persona["name"]}
当前对话 {curr_cid_title} 的人格情景: {curr_persona_name}
配置人格情景请前往管理面板-配置页
""",
)
.use_t2i(False),
)
elif l[1] == "list":
# 获取文件夹树和所有人格
folder_tree = await self.context.persona_manager.get_folder_tree()
all_personas = self.context.persona_manager.personas
lines = ["📂 人格列表:\n"]
# 构建树状输出
tree_lines = self._build_tree_output(folder_tree, all_personas)
lines.extend(tree_lines)
# 输出根目录下的人格(没有文件夹的)
root_personas = [p for p in all_personas if p.folder_id is None]
if root_personas:
if tree_lines: # 如果有文件夹内容,加个空行
lines.append("")
for persona in root_personas:
lines.append(f"👤 {persona.persona_id}")
# 统计信息
total_count = len(all_personas)
lines.append(f"\n{total_count} 个人格")
lines.append("\n*使用 `/persona <人格名>` 设置人格")
lines.append("*使用 `/persona view <人格名>` 查看详细信息")
msg = "\n".join(lines)
message.set_result(MessageEventResult().message(msg).use_t2i(False))
elif l[1] == "view":
if len(l) == 2:
message.set_result(MessageEventResult().message("请输入人格情景名"))
return
ps = l[2].strip()
if persona := next(
builtins.filter(
lambda persona: persona["name"] == ps,
self.context.provider_manager.personas,
),
None,
):
msg = f"人格{ps}的详细信息:\n"
msg += f"{persona['prompt']}\n"
else:
msg = f"人格{ps}不存在"
message.set_result(MessageEventResult().message(msg))
elif l[1] == "unset":
if not cid:
message.set_result(
MessageEventResult().message("当前没有对话,无法取消人格。"),
)
return
await self.context.conversation_manager.update_conversation_persona_id(
message.unified_msg_origin,
"[%None]",
)
message.set_result(MessageEventResult().message("取消人格成功。"))
else:
ps = "".join(l[1:]).strip()
if not cid:
message.set_result(
MessageEventResult().message(
"当前没有对话,请先开始对话或使用 /new 创建一个对话。",
),
)
return
if persona := next(
builtins.filter(
lambda persona: persona["name"] == ps,
self.context.provider_manager.personas,
),
None,
):
await self.context.conversation_manager.update_conversation_persona_id(
message.unified_msg_origin,
ps,
)
force_warn_msg = ""
if force_applied_persona_id:
force_warn_msg = (
"提醒:由于自定义规则,您现在切换的人格将不会生效。"
)
message.set_result(
MessageEventResult().message(
f"设置成功。如果您正在切换到不同的人格,请注意使用 /reset 来清空上下文,防止原人格对话影响现人格。{force_warn_msg}",
),
)
else:
message.set_result(
MessageEventResult().message(
"不存在该人格情景。使用 /persona list 查看所有。",
),
)

View File

@@ -1,120 +0,0 @@
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core import DEMO_MODE, logger
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
from astrbot.core.star.star_manager import PluginManager
class PluginCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
async def plugin_ls(self, event: AstrMessageEvent) -> None:
"""获取已经安装的插件列表。"""
parts = ["已加载的插件:\n"]
for plugin in self.context.get_all_stars():
line = f"- `{plugin.name}` By {plugin.author}: {plugin.desc}"
if not plugin.activated:
line += " (未启用)"
parts.append(line + "\n")
if len(parts) == 1:
plugin_list_info = "没有加载任何插件。"
else:
plugin_list_info = "".join(parts)
plugin_list_info += "\n使用 /plugin help <插件名> 查看插件帮助和加载的指令。\n使用 /plugin on/off <插件名> 启用或者禁用插件。"
event.set_result(
MessageEventResult().message(f"{plugin_list_info}").use_t2i(False),
)
async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
"""禁用插件"""
if DEMO_MODE:
event.set_result(MessageEventResult().message("演示模式下无法禁用插件。"))
return
if not plugin_name:
event.set_result(
MessageEventResult().message("/plugin off <插件名> 禁用插件。"),
)
return
await self.context._star_manager.turn_off_plugin(plugin_name) # type: ignore
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已禁用。"))
async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
"""启用插件"""
if DEMO_MODE:
event.set_result(MessageEventResult().message("演示模式下无法启用插件。"))
return
if not plugin_name:
event.set_result(
MessageEventResult().message("/plugin on <插件名> 启用插件。"),
)
return
await self.context._star_manager.turn_on_plugin(plugin_name) # type: ignore
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已启用。"))
async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = "") -> None:
"""安装插件"""
if DEMO_MODE:
event.set_result(MessageEventResult().message("演示模式下无法安装插件。"))
return
if not plugin_repo:
event.set_result(
MessageEventResult().message("/plugin get <插件仓库地址> 安装插件"),
)
return
logger.info(f"准备从 {plugin_repo} 安装插件。")
if self.context._star_manager:
star_mgr: PluginManager = self.context._star_manager
try:
await star_mgr.install_plugin(plugin_repo) # type: ignore
event.set_result(MessageEventResult().message("安装插件成功。"))
except Exception as e:
logger.error(f"安装插件失败: {e}")
event.set_result(MessageEventResult().message(f"安装插件失败: {e}"))
return
async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
"""获取插件帮助"""
if not plugin_name:
event.set_result(
MessageEventResult().message("/plugin help <插件名> 查看插件信息。"),
)
return
plugin = self.context.get_registered_star(plugin_name)
if plugin is None:
event.set_result(MessageEventResult().message("未找到此插件。"))
return
help_msg = ""
help_msg += f"\n\n✨ 作者: {plugin.author}\n✨ 版本: {plugin.version}"
command_handlers = []
command_names = []
for handler in star_handlers_registry:
assert isinstance(handler, StarHandlerMetadata)
if handler.handler_module_path != plugin.module_path:
continue
for filter_ in handler.event_filters:
if isinstance(filter_, CommandFilter):
command_handlers.append(handler)
command_names.append(filter_.command_name)
break
if isinstance(filter_, CommandGroupFilter):
command_handlers.append(handler)
command_names.append(filter_.group_name)
if len(command_handlers) > 0:
parts = ["\n\n🔧 指令列表:\n"]
for i in range(len(command_handlers)):
line = f"- {command_names[i]}"
if command_handlers[i].desc:
line += f": {command_handlers[i].desc}"
parts.append(line + "\n")
parts.append("\nTip: 指令的触发需要添加唤醒前缀,默认为 /。")
help_msg += "".join(parts)
ret = f"🧩 插件 {plugin_name} 帮助信息:\n" + help_msg
ret += "更多帮助信息请查看插件仓库 README。"
event.set_result(MessageEventResult().message(ret).use_t2i(False))

View File

@@ -1,736 +0,0 @@
from __future__ import annotations
import asyncio
import time
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING
from astrbot import logger
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.provider.entities import ProviderType
from astrbot.core.utils.error_redaction import safe_error
if TYPE_CHECKING:
from astrbot.core.provider.provider import Provider
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT = 30.0
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT = 4
MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND = 16
MODEL_LIST_CACHE_TTL_KEY = "model_list_cache_ttl_seconds"
MODEL_LOOKUP_MAX_CONCURRENCY_KEY = "model_lookup_max_concurrency"
MODEL_CACHE_MAX_ENTRIES = 512
@dataclass(frozen=True)
class _ModelLookupConfig:
umo: str | None
cache_ttl_seconds: float
max_concurrency: int
class _ModelCache:
def __init__(self) -> None:
self._store: dict[tuple[str, str | None], tuple[float, list[str]]] = {}
def get(self, provider_id: str, umo: str | None, ttl: float) -> list[str] | None:
if ttl <= 0:
return None
entry = self._store.get((provider_id, umo))
if not entry:
return None
timestamp, models = entry
if time.monotonic() - timestamp > ttl:
self._store.pop((provider_id, umo), None)
return None
return models
def set(
self, provider_id: str, umo: str | None, models: list[str], ttl: float
) -> None:
if ttl <= 0:
return
self._store[(provider_id, umo)] = (time.monotonic(), list(models))
self._evict_if_needed()
def _evict_if_needed(self) -> None:
if len(self._store) <= MODEL_CACHE_MAX_ENTRIES:
return
# Drop oldest entries first when cache grows too large.
overflow = len(self._store) - MODEL_CACHE_MAX_ENTRIES
for key, _ in sorted(
self._store.items(),
key=lambda item: item[1][0],
)[:overflow]:
self._store.pop(key, None)
def invalidate(
self, provider_id: str | None = None, *, umo: str | None = None
) -> None:
if provider_id is None:
self._store.clear()
return
if umo is not None:
self._store.pop((provider_id, umo), None)
return
stale_keys = [
cache_key for cache_key in self._store if cache_key[0] == provider_id
]
for cache_key in stale_keys:
self._store.pop(cache_key, None)
class ProviderCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
self._model_cache = _ModelCache()
self._register_provider_change_hook()
def _register_provider_change_hook(self) -> None:
set_change_callback = getattr(
self.context.provider_manager,
"set_provider_change_callback",
None,
)
if callable(set_change_callback):
set_change_callback(self._on_provider_manager_changed)
return
register_change_hook = getattr(
self.context.provider_manager,
"register_provider_change_hook",
None,
)
if callable(register_change_hook):
register_change_hook(self._on_provider_manager_changed)
def invalidate_provider_models_cache(
self, provider_id: str | None = None, *, umo: str | None = None
) -> None:
"""Public hook for cache invalidation on external provider config changes."""
self._model_cache.invalidate(provider_id, umo=umo)
def _on_provider_manager_changed(
self,
provider_id: str,
provider_type: ProviderType,
umo: str | None,
) -> None:
if provider_type == ProviderType.CHAT_COMPLETION:
self.invalidate_provider_models_cache(provider_id, umo=umo)
def _get_provider_settings(self, umo: str | None) -> dict:
if not umo:
return {}
try:
return self.context.get_config(umo).get("provider_settings", {}) or {}
except Exception as e:
logger.debug(
"读取 provider_settings 失败,使用默认值: %s",
safe_error("", e),
)
return {}
def _get_model_cache_ttl(self, umo: str | None) -> float:
settings = self._get_provider_settings(umo)
raw = settings.get(
MODEL_LIST_CACHE_TTL_KEY,
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
)
try:
return max(float(raw), 0.0)
except Exception as e:
logger.debug(
"读取 %s 失败,回退默认值 %r: %s",
MODEL_LIST_CACHE_TTL_KEY,
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
safe_error("", e),
)
return MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT
def _get_model_lookup_concurrency(self, umo: str | None) -> int:
settings = self._get_provider_settings(umo)
raw = settings.get(
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
)
try:
value = int(raw)
except Exception as e:
logger.debug(
"读取 %s 失败,回退默认值 %r: %s",
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
safe_error("", e),
)
value = MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT
return min(max(value, 1), MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND)
def _get_model_lookup_config(self, umo: str | None) -> _ModelLookupConfig:
return _ModelLookupConfig(
umo=umo,
cache_ttl_seconds=self._get_model_cache_ttl(umo),
max_concurrency=self._get_model_lookup_concurrency(umo),
)
def _resolve_model_name(
self,
model_name: str,
models: Sequence[str],
) -> str | None:
"""Resolve model name with precedence:
exact > case-insensitive > provider-qualified suffix.
"""
requested = model_name.strip()
if not requested:
return None
requested_norm = requested.casefold()
# exact / case-insensitive match
for candidate in models:
if candidate == requested or candidate.casefold() == requested_norm:
return candidate
# provider-qualified suffix match:
# e.g. candidate `openai/gpt-4o` should match requested `gpt-4o`.
for candidate in models:
cand_norm = candidate.casefold()
if cand_norm.endswith(f"/{requested_norm}") or cand_norm.endswith(
f":{requested_norm}"
):
return candidate
return None
def _apply_model(
self, prov: Provider, model_name: str, *, umo: str | None = None
) -> str:
prov.set_model(model_name)
self.invalidate_provider_models_cache(prov.meta().id, umo=umo)
return f"切换模型成功。当前提供商: [{prov.meta().id}] 当前模型: [{prov.get_model()}]"
async def _get_provider_models(
self,
provider: Provider,
*,
config: _ModelLookupConfig,
use_cache: bool = True,
) -> list[str]:
provider_id = provider.meta().id
ttl_seconds = config.cache_ttl_seconds
umo = config.umo
if use_cache:
cached = self._model_cache.get(provider_id, umo, ttl_seconds)
if cached is not None:
return cached
models = list(await provider.get_models())
if use_cache:
self._model_cache.set(provider_id, umo, models, ttl_seconds)
return models
async def _get_models_or_reply_error(
self,
message: AstrMessageEvent,
prov: Provider,
config: _ModelLookupConfig,
*,
error_prefix: str,
disable_t2i: bool = False,
warning_log: str | None = None,
) -> list[str] | None:
try:
return await self._get_provider_models(prov, config=config)
except asyncio.CancelledError:
raise
except Exception as e:
if warning_log is not None:
logger.warning(
warning_log,
prov.meta().id,
safe_error("", e),
)
result = MessageEventResult().message(safe_error(error_prefix, e))
if disable_t2i:
result = result.use_t2i(False)
message.set_result(result)
return None
def _log_reachability_failure(
self,
provider,
provider_capability_type: ProviderType | None,
err_code: str,
err_reason: str,
) -> None:
"""记录不可达原因到日志。"""
meta = provider.meta()
logger.warning(
"Provider reachability check failed: id=%s type=%s code=%s reason=%s",
meta.id,
provider_capability_type.name if provider_capability_type else "unknown",
err_code,
err_reason,
)
async def _test_provider_capability(self, provider):
"""测试单个 provider 的可用性"""
meta = provider.meta()
provider_capability_type = meta.provider_type
try:
await provider.test()
return True, None, None
except Exception as e:
err_code = "TEST_FAILED"
err_reason = safe_error("", e)
self._log_reachability_failure(
provider, provider_capability_type, err_code, err_reason
)
return False, err_code, err_reason
async def _find_provider_for_model(
self,
model_name: str,
*,
exclude_provider_id: str | None = None,
config: _ModelLookupConfig,
use_cache: bool = True,
) -> tuple[Provider | None, str | None]:
all_providers = []
for provider in self.context.get_all_providers():
provider_meta = provider.meta()
if provider_meta.provider_type != ProviderType.CHAT_COMPLETION:
continue
if (
exclude_provider_id is not None
and provider_meta.id == exclude_provider_id
):
continue
all_providers.append(provider)
if not all_providers:
return None, None
semaphore = asyncio.Semaphore(config.max_concurrency)
async def fetch_models(
provider: Provider,
) -> tuple[Provider, list[str] | None, str | None]:
async with semaphore:
try:
models = await self._get_provider_models(
provider,
config=config,
use_cache=use_cache,
)
return provider, models, None
except asyncio.CancelledError:
raise
except Exception as e:
err = safe_error("", e)
logger.debug(
"跨提供商查找模型 %s 获取 %s 模型列表失败: %s",
model_name,
provider.meta().id,
err,
)
return provider, None, err
results = await asyncio.gather(
*(fetch_models(provider) for provider in all_providers)
)
failed_provider_errors: list[tuple[str, str]] = []
for provider, models, err in results:
if err is not None:
failed_provider_errors.append((provider.meta().id, err))
continue
if models is None:
continue
matched_model_name = self._resolve_model_name(model_name, models)
if matched_model_name is not None:
return provider, matched_model_name
if failed_provider_errors and len(failed_provider_errors) == len(all_providers):
failed_ids = ",".join(
provider_id for provider_id, _ in failed_provider_errors
)
logger.error(
"跨提供商查找模型 %s 时,所有 %d 个提供商的 get_models() 均失败: %s。请检查配置或网络",
model_name,
len(all_providers),
failed_ids,
)
elif failed_provider_errors:
logger.debug(
"跨提供商查找模型 %s 时有 %d 个提供商获取模型失败: %s",
model_name,
len(failed_provider_errors),
",".join(
f"{provider_id}({error})"
for provider_id, error in failed_provider_errors
),
)
return None, None
async def provider(
self,
event: AstrMessageEvent,
idx: str | int | None = None,
idx2: int | None = None,
) -> None:
"""查看或者切换 LLM Provider"""
umo = event.unified_msg_origin
cfg = self.context.get_config(umo).get("provider_settings", {})
reachability_check_enabled = cfg.get("reachability_check", True)
if idx is None:
parts = ["## 载入的 LLM 提供商\n"]
# 获取所有类型的提供商
llms = list(self.context.get_all_providers())
ttss = self.context.get_all_tts_providers()
stts = self.context.get_all_stt_providers()
# 构造待检测列表: [(provider, type_label), ...]
all_providers = []
all_providers.extend([(p, "llm") for p in llms])
all_providers.extend([(p, "tts") for p in ttss])
all_providers.extend([(p, "stt") for p in stts])
# 并发测试连通性
if reachability_check_enabled:
if all_providers:
await event.send(
MessageEventResult().message(
"正在进行提供商可达性测试,请稍候..."
)
)
check_results = await asyncio.gather(
*[self._test_provider_capability(p) for p, _ in all_providers],
return_exceptions=True,
)
else:
# 用 None 表示未检测
check_results = [None for _ in all_providers]
# 整合结果
display_data = []
for (p, p_type), reachable in zip(all_providers, check_results):
meta = p.meta()
id_ = meta.id
error_code = None
if isinstance(reachable, asyncio.CancelledError):
raise reachable
if isinstance(reachable, Exception):
# 异常情况下兜底处理,避免单个 provider 导致列表失败
self._log_reachability_failure(
p,
None,
reachable.__class__.__name__,
safe_error("", reachable),
)
reachable_flag = False
error_code = reachable.__class__.__name__
elif isinstance(reachable, tuple):
reachable_flag, error_code, _ = reachable
else:
reachable_flag = reachable
# 根据类型构建显示名称
if p_type == "llm":
info = f"{id_} ({meta.model})"
else:
info = f"{id_}"
# 确定状态标记
if reachable_flag is True:
mark = ""
elif reachable_flag is False:
if error_code:
mark = f" ❌(错误码: {error_code})"
else:
mark = ""
else:
mark = "" # 不支持检测时不显示标记
display_data.append(
{
"type": p_type,
"info": info,
"mark": mark,
"provider": p,
}
)
# 分组输出
# 1. LLM
llm_data = [d for d in display_data if d["type"] == "llm"]
for i, d in enumerate(llm_data):
line = f"{i + 1}. {d['info']}{d['mark']}"
provider_using = self.context.get_using_provider(umo=umo)
if (
provider_using
and provider_using.meta().id == d["provider"].meta().id
):
line += " (当前使用)"
parts.append(line + "\n")
# 2. TTS
tts_data = [d for d in display_data if d["type"] == "tts"]
if tts_data:
parts.append("\n## 载入的 TTS 提供商\n")
for i, d in enumerate(tts_data):
line = f"{i + 1}. {d['info']}{d['mark']}"
tts_using = self.context.get_using_tts_provider(umo=umo)
if tts_using and tts_using.meta().id == d["provider"].meta().id:
line += " (当前使用)"
parts.append(line + "\n")
# 3. STT
stt_data = [d for d in display_data if d["type"] == "stt"]
if stt_data:
parts.append("\n## 载入的 STT 提供商\n")
for i, d in enumerate(stt_data):
line = f"{i + 1}. {d['info']}{d['mark']}"
stt_using = self.context.get_using_stt_provider(umo=umo)
if stt_using and stt_using.meta().id == d["provider"].meta().id:
line += " (当前使用)"
parts.append(line + "\n")
parts.append("\n使用 /provider <序号> 切换 LLM 提供商。")
ret = "".join(parts)
if ttss:
ret += "\n使用 /provider tts <序号> 切换 TTS 提供商。"
if stts:
ret += "\n使用 /provider stt <序号> 切换 STT 提供商。"
if not reachability_check_enabled:
ret += "\n已跳过提供商可达性检测,如需检测请在配置文件中开启。"
event.set_result(MessageEventResult().message(ret))
elif idx == "tts":
if idx2 is None:
event.set_result(MessageEventResult().message("请输入序号。"))
return
if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1:
event.set_result(MessageEventResult().message("无效的提供商序号。"))
return
provider = self.context.get_all_tts_providers()[idx2 - 1]
id_ = provider.meta().id
await self.context.provider_manager.set_provider(
provider_id=id_,
provider_type=ProviderType.TEXT_TO_SPEECH,
umo=umo,
)
event.set_result(MessageEventResult().message(f"成功切换到 {id_}"))
elif idx == "stt":
if idx2 is None:
event.set_result(MessageEventResult().message("请输入序号。"))
return
if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1:
event.set_result(MessageEventResult().message("无效的提供商序号。"))
return
provider = self.context.get_all_stt_providers()[idx2 - 1]
id_ = provider.meta().id
await self.context.provider_manager.set_provider(
provider_id=id_,
provider_type=ProviderType.SPEECH_TO_TEXT,
umo=umo,
)
event.set_result(MessageEventResult().message(f"成功切换到 {id_}"))
elif isinstance(idx, int):
if idx > len(self.context.get_all_providers()) or idx < 1:
event.set_result(MessageEventResult().message("无效的提供商序号。"))
return
provider = self.context.get_all_providers()[idx - 1]
id_ = provider.meta().id
await self.context.provider_manager.set_provider(
provider_id=id_,
provider_type=ProviderType.CHAT_COMPLETION,
umo=umo,
)
event.set_result(MessageEventResult().message(f"成功切换到 {id_}"))
else:
event.set_result(MessageEventResult().message("无效的参数。"))
async def _switch_model_by_name(
self, message: AstrMessageEvent, model_name: str, prov: Provider
) -> None:
model_name = model_name.strip()
if not model_name:
message.set_result(MessageEventResult().message("模型名不能为空。"))
return
umo = message.unified_msg_origin
config = self._get_model_lookup_config(umo)
curr_provider_id = prov.meta().id
models = await self._get_models_or_reply_error(
message,
prov,
config,
error_prefix="获取当前提供商模型列表失败: ",
warning_log="获取当前提供商 %s 模型列表失败,停止跨提供商查找: %s",
)
if models is None:
return
matched_model_name = self._resolve_model_name(model_name, models)
if matched_model_name is not None:
message.set_result(
MessageEventResult().message(
self._apply_model(prov, matched_model_name, umo=umo)
),
)
return
target_prov, matched_target_model_name = await self._find_provider_for_model(
model_name,
exclude_provider_id=curr_provider_id,
config=config,
)
if target_prov is None or matched_target_model_name is None:
message.set_result(
MessageEventResult().message(
f"模型 [{model_name}] 未在任何已配置的提供商中找到,或所有提供商模型列表获取失败,请检查配置或网络后重试。",
),
)
return
target_id = target_prov.meta().id
try:
await self.context.provider_manager.set_provider(
provider_id=target_id,
provider_type=ProviderType.CHAT_COMPLETION,
umo=umo,
)
self._apply_model(target_prov, matched_target_model_name, umo=umo)
message.set_result(
MessageEventResult().message(
f"检测到模型 [{matched_target_model_name}] 属于提供商 [{target_id}],已自动切换提供商并设置模型。",
),
)
except asyncio.CancelledError:
raise
except Exception as e:
message.set_result(
MessageEventResult().message(
safe_error("跨提供商切换并设置模型失败: ", e)
),
)
async def model_ls(
self,
message: AstrMessageEvent,
idx_or_name: int | str | None = None,
) -> None:
"""查看或者切换模型"""
prov = self.context.get_using_provider(message.unified_msg_origin)
if not prov:
message.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
config = self._get_model_lookup_config(message.unified_msg_origin)
if idx_or_name is None:
models = await self._get_models_or_reply_error(
message,
prov,
config,
error_prefix="获取模型列表失败: ",
disable_t2i=True,
)
if models is None:
return
parts = ["下面列出了此模型提供商可用模型:"]
for i, model in enumerate(models, 1):
parts.append(f"\n{i}. {model}")
curr_model = prov.get_model() or ""
parts.append(f"\n当前模型: [{curr_model}]")
parts.append(
"\nTips: 使用 /model <模型名/编号> 切换模型。输入模型名时可自动跨提供商查找并切换;跨提供商也可使用 /provider 切换。"
)
ret = "".join(parts)
message.set_result(MessageEventResult().message(ret).use_t2i(False))
elif isinstance(idx_or_name, int):
models = await self._get_models_or_reply_error(
message,
prov,
config,
error_prefix="获取模型列表失败: ",
)
if models is None:
return
if idx_or_name > len(models) or idx_or_name < 1:
message.set_result(MessageEventResult().message("模型序号错误。"))
else:
try:
new_model = models[idx_or_name - 1]
message.set_result(
MessageEventResult().message(
self._apply_model(
prov,
new_model,
umo=message.unified_msg_origin,
)
),
)
except Exception as e:
message.set_result(
MessageEventResult().message(
safe_error("切换模型未知错误: ", e)
),
)
return
else:
await self._switch_model_by_name(message, idx_or_name, prov)
async def key(self, message: AstrMessageEvent, index: int | None = None) -> None:
prov = self.context.get_using_provider(message.unified_msg_origin)
if not prov:
message.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
if index is None:
keys_data = prov.get_keys()
curr_key = prov.get_current_key()
parts = ["Key:"]
for i, k in enumerate(keys_data, 1):
parts.append(f"\n{i}. {k[:8]}")
parts.append(f"\n当前 Key: {curr_key[:8]}")
parts.append("\n当前模型: " + prov.get_model())
parts.append("\n使用 /key <idx> 切换 Key。")
ret = "".join(parts)
message.set_result(MessageEventResult().message(ret).use_t2i(False))
else:
keys_data = prov.get_keys()
if index > len(keys_data) or index < 1:
message.set_result(MessageEventResult().message("Key 序号错误。"))
else:
try:
new_key = keys_data[index - 1]
prov.set_key(new_key)
self.invalidate_provider_models_cache(
prov.meta().id,
umo=message.unified_msg_origin,
)
message.set_result(MessageEventResult().message("切换 Key 成功。"))
except Exception as e:
message.set_result(
MessageEventResult().message(
safe_error("切换 Key 未知错误: ", e)
),
)
return

View File

@@ -18,19 +18,19 @@ class SIDCommand:
umo_msg_type = event.session.message_type.value
umo_session_id = event.session.session_id
ret = (
f"UMO: 「{sid} 此值可用于设置白名单。\n"
f"UID: 「{user_id} 此值可用于设置管理员。\n"
f"消息会话来源信息:\n"
f" 机器人 ID: 「{umo_platform}\n"
f" 消息类型: 「{umo_msg_type}\n"
f" 会话 ID: 「{umo_session_id}\n"
f"消息来源可用于配置机器人的配置文件路由。"
f"UMO: 「{sid}\n"
f"UID: 「{user_id}\n"
"*Use UMO to set whitelist and configure routing, use UID to set admin list(UMO 可用于设置白名单和配置文件路由UID 可用于设置管理员列表)\n\n"
f"Your session information:\n"
f"Bot ID: 「{umo_platform}\n"
f"Message Type: 「{umo_msg_type}\n"
f"Session ID: 「{umo_session_id}\n\n"
)
if (
self.context.get_config()["platform_settings"]["unique_session"]
and event.get_group_id()
):
ret += f"\n\n当前处于独立会话模式, 此群 ID: 「{event.get_group_id()}, 也可将此 ID 加入白名单来放行整个群聊。"
ret += f"\n\nThe group's ID: 「{event.get_group_id()}. Set this ID to whitelist to allow the entire group."
event.set_result(MessageEventResult().message(ret).use_t2i(False))

View File

@@ -1,23 +0,0 @@
"""文本转图片命令"""
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
class T2ICommand:
"""文本转图片命令类"""
def __init__(self, context: star.Context) -> None:
self.context = context
async def t2i(self, event: AstrMessageEvent) -> None:
"""开关文本转图片"""
config = self.context.get_config(umo=event.unified_msg_origin)
if config["t2i"]:
config["t2i"] = False
config.save_config()
event.set_result(MessageEventResult().message("已关闭文本转图片模式。"))
return
config["t2i"] = True
config.save_config()
event.set_result(MessageEventResult().message("已开启文本转图片模式。"))

View File

@@ -1,36 +0,0 @@
"""文本转语音命令"""
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.star.session_llm_manager import SessionServiceManager
class TTSCommand:
"""文本转语音命令类"""
def __init__(self, context: star.Context) -> None:
self.context = context
async def tts(self, event: AstrMessageEvent) -> None:
"""开关文本转语音(会话级别)"""
umo = event.unified_msg_origin
ses_tts = await SessionServiceManager.is_tts_enabled_for_session(umo)
cfg = self.context.get_config(umo=umo)
tts_enable = cfg["provider_tts_settings"]["enable"]
# 切换状态
new_status = not ses_tts
await SessionServiceManager.set_tts_status_for_session(umo, new_status)
status_text = "已开启" if new_status else "已关闭"
if new_status and not tts_enable:
event.set_result(
MessageEventResult().message(
f"{status_text}当前会话的文本转语音。但 TTS 功能在配置中未启用,请前往 WebUI 开启。",
),
)
else:
event.set_result(
MessageEventResult().message(f"{status_text}当前会话的文本转语音。"),
)

View File

@@ -3,17 +3,10 @@ from astrbot.api.event import AstrMessageEvent, filter
from .commands import (
AdminCommands,
AlterCmdCommands,
ConversationCommands,
HelpCommand,
LLMCommands,
PersonaCommands,
PluginCommands,
ProviderCommands,
SetUnsetCommands,
SIDCommand,
T2ICommand,
TTSCommand,
)
@@ -21,198 +14,49 @@ class Main(star.Star):
def __init__(self, context: star.Context) -> None:
self.context = context
self.help_c = HelpCommand(self.context)
self.llm_c = LLMCommands(self.context)
self.plugin_c = PluginCommands(self.context)
self.admin_c = AdminCommands(self.context)
self.conversation_c = ConversationCommands(self.context)
self.provider_c = ProviderCommands(self.context)
self.persona_c = PersonaCommands(self.context)
self.alter_cmd_c = AlterCmdCommands(self.context)
self.help_c = HelpCommand(self.context)
self.setunset_c = SetUnsetCommands(self.context)
self.t2i_c = T2ICommand(self.context)
self.tts_c = TTSCommand(self.context)
self.sid_c = SIDCommand(self.context)
@filter.command("help")
async def help(self, event: AstrMessageEvent) -> None:
"""查看帮助"""
"""Show help message"""
await self.help_c.help(event)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("llm")
async def llm(self, event: AstrMessageEvent) -> None:
"""开启/关闭 LLM"""
await self.llm_c.llm(event)
@filter.command_group("plugin")
def plugin(self) -> None:
"""插件管理"""
@plugin.command("ls")
async def plugin_ls(self, event: AstrMessageEvent) -> None:
"""获取已经安装的插件列表。"""
await self.plugin_c.plugin_ls(event)
@filter.permission_type(filter.PermissionType.ADMIN)
@plugin.command("off")
async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
"""禁用插件"""
await self.plugin_c.plugin_off(event, plugin_name)
@filter.permission_type(filter.PermissionType.ADMIN)
@plugin.command("on")
async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
"""启用插件"""
await self.plugin_c.plugin_on(event, plugin_name)
@filter.permission_type(filter.PermissionType.ADMIN)
@plugin.command("get")
async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = "") -> None:
"""安装插件"""
await self.plugin_c.plugin_get(event, plugin_repo)
@plugin.command("help")
async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
"""获取插件帮助"""
await self.plugin_c.plugin_help(event, plugin_name)
@filter.command("t2i")
async def t2i(self, event: AstrMessageEvent) -> None:
"""开关文本转图片"""
await self.t2i_c.t2i(event)
@filter.command("tts")
async def tts(self, event: AstrMessageEvent) -> None:
"""开关文本转语音(会话级别)"""
await self.tts_c.tts(event)
@filter.command("sid")
async def sid(self, event: AstrMessageEvent) -> None:
"""获取会话 ID 和 管理员 ID"""
"""Get session ID and other related information"""
await self.sid_c.sid(event)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("op")
async def op(self, event: AstrMessageEvent, admin_id: str = "") -> None:
"""授权管理员。op <admin_id>"""
await self.admin_c.op(event, admin_id)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("deop")
async def deop(self, event: AstrMessageEvent, admin_id: str) -> None:
"""取消授权管理员。deop <admin_id>"""
await self.admin_c.deop(event, admin_id)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("wl")
async def wl(self, event: AstrMessageEvent, sid: str = "") -> None:
"""添加白名单。wl <sid>"""
await self.admin_c.wl(event, sid)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("dwl")
async def dwl(self, event: AstrMessageEvent, sid: str) -> None:
"""删除白名单。dwl <sid>"""
await self.admin_c.dwl(event, sid)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("provider")
async def provider(
self,
event: AstrMessageEvent,
idx: str | int | None = None,
idx2: int | None = None,
) -> None:
"""查看或者切换 LLM Provider"""
await self.provider_c.provider(event, idx, idx2)
@filter.command("reset")
async def reset(self, message: AstrMessageEvent) -> None:
"""重置 LLM 会话"""
"""Reset conversation history"""
await self.conversation_c.reset(message)
@filter.command("stop")
async def stop(self, message: AstrMessageEvent) -> None:
"""停止当前会话中正在运行的 Agent"""
"""Stop agent execution"""
await self.conversation_c.stop(message)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("model")
async def model_ls(
self,
message: AstrMessageEvent,
idx_or_name: int | str | None = None,
) -> None:
"""查看或者切换模型"""
await self.provider_c.model_ls(message, idx_or_name)
@filter.command("history")
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
"""查看对话记录"""
await self.conversation_c.his(message, page)
@filter.command("ls")
async def convs(self, message: AstrMessageEvent, page: int = 1) -> None:
"""查看对话列表"""
await self.conversation_c.convs(message, page)
@filter.command("new")
async def new_conv(self, message: AstrMessageEvent) -> None:
"""创建新对话"""
"""Create new conversation"""
await self.conversation_c.new_conv(message)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("groupnew")
async def groupnew_conv(self, message: AstrMessageEvent, sid: str) -> None:
"""创建新群聊对话"""
await self.conversation_c.groupnew_conv(message, sid)
@filter.command("switch")
async def switch_conv(
self, message: AstrMessageEvent, index: int | None = None
) -> None:
"""通过 /ls 前面的序号切换对话"""
await self.conversation_c.switch_conv(message, index)
@filter.command("rename")
async def rename_conv(self, message: AstrMessageEvent, new_name: str) -> None:
"""重命名对话"""
await self.conversation_c.rename_conv(message, new_name)
@filter.command("del")
async def del_conv(self, message: AstrMessageEvent) -> None:
"""删除当前对话"""
await self.conversation_c.del_conv(message)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("key")
async def key(self, message: AstrMessageEvent, index: int | None = None) -> None:
"""查看或者切换 Key"""
await self.provider_c.key(message, index)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("persona")
async def persona(self, message: AstrMessageEvent) -> None:
"""查看或者切换 Persona"""
await self.persona_c.persona(message)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("dashboard_update")
async def update_dashboard(self, event: AstrMessageEvent) -> None:
"""更新管理面板"""
"""Update AstrBot WebUI"""
await self.admin_c.update_dashboard(event)
@filter.command("set")
async def set_variable(self, event: AstrMessageEvent, key: str, value: str) -> None:
"""Set session variable"""
await self.setunset_c.set_variable(event, key, value)
@filter.command("unset")
async def unset_variable(self, event: AstrMessageEvent, key: str) -> None:
"""Unset session variable"""
await self.setunset_c.unset_variable(event, key)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("alter_cmd", alias={"alter"})
async def alter_cmd(self, event: AstrMessageEvent) -> None:
"""修改命令权限"""
await self.alter_cmd_c.alter_cmd(event)

View File

@@ -1 +1 @@
__version__ = "4.22.3"
__version__ = "4.23.0"

View File

@@ -1,9 +1,11 @@
import asyncio
import logging
import os
import re
import sys
from contextlib import AsyncExitStack
from datetime import timedelta
from pathlib import Path, PureWindowsPath
from typing import Generic
from tenacity import (
@@ -21,6 +23,75 @@ from astrbot.core.utils.log_pipe import LogPipe
from .run_context import TContext
from .tool import FunctionTool
_DEFAULT_STDIO_COMMAND_ALLOWLIST = frozenset(
{
"python",
"python3",
"py",
"node",
"npx",
"npm",
"pnpm",
"yarn",
"bun",
"bunx",
"deno",
"uv",
"uvx",
}
)
_DENIED_STDIO_COMMANDS = frozenset(
{
"bash",
"sh",
"zsh",
"fish",
"cmd",
"cmd.exe",
"powershell",
"powershell.exe",
"pwsh",
"pwsh.exe",
"osascript",
"open",
"curl",
"wget",
"nc",
"netcat",
"telnet",
"ssh",
"scp",
"rm",
"mv",
"cp",
"dd",
"mkfs",
"sudo",
"su",
"chmod",
"chown",
"kill",
"killall",
"shutdown",
"reboot",
"poweroff",
"halt",
}
)
_SHELL_META_RE = re.compile(r"[\r\n\x00;&|<>`$]")
_PYTHON_INLINE_CODE_FLAGS = frozenset({"-c"})
_JS_INLINE_CODE_FLAGS = frozenset({"-e", "--eval", "-p", "--print"})
_DENIED_DOCKER_ARGS = frozenset(
{
"--privileged",
"--pid=host",
"--network=host",
"--net=host",
"--ipc=host",
}
)
_STDIO_ALLOWLIST_ENV = "ASTRBOT_MCP_STDIO_ALLOWED_COMMANDS"
try:
import anyio
import mcp
@@ -42,11 +113,129 @@ def _prepare_config(config: dict) -> dict:
"""Prepare configuration, handle nested format"""
if config.get("mcpServers"):
first_key = next(iter(config["mcpServers"]))
config = config["mcpServers"][first_key]
config = dict(config["mcpServers"][first_key])
else:
config = dict(config)
config.pop("active", None)
return config
def _normalize_stdio_command_name(command: str) -> str:
command = command.strip()
if "\\" in command:
command_name = PureWindowsPath(command).name
else:
command_name = Path(command).name
command_name = command_name.lower()
for suffix in (".exe", ".cmd", ".bat"):
if command_name.endswith(suffix):
return command_name[: -len(suffix)]
return command_name
def _get_stdio_command_allowlist() -> set[str]:
allowed = set(_DEFAULT_STDIO_COMMAND_ALLOWLIST)
configured = os.environ.get(_STDIO_ALLOWLIST_ENV, "")
if configured.strip():
allowed = {
_normalize_stdio_command_name(item)
for item in configured.split(",")
if item.strip()
}
return allowed
def _is_stdio_config(config: dict) -> bool:
cfg = _prepare_config(config.copy())
return "url" not in cfg
def _validate_stdio_args(command_name: str, args: object) -> None:
if args is None:
return
if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
raise ValueError("MCP stdio args must be a list of strings.")
for arg in args:
if "\x00" in arg or "\r" in arg or "\n" in arg:
raise ValueError("MCP stdio args cannot contain control characters.")
if command_name.startswith("python") or command_name == "py":
if any(
arg == "-c"
or (arg.startswith("-") and not arg.startswith("--") and "c" in arg)
for arg in args
):
raise ValueError(
"MCP stdio Python servers must be launched from a module or file; inline code flags such as -c are not allowed."
)
elif command_name in {"node", "deno", "bun"} or command_name.startswith("node"):
if any(
arg in _JS_INLINE_CODE_FLAGS
or arg == "eval"
or (
arg.startswith("-")
and not arg.startswith("--")
and any(c in arg for c in "ep")
)
for arg in args
):
raise ValueError(
"MCP stdio JavaScript servers must be launched from a package or file; inline eval flags are not allowed."
)
elif command_name == "docker":
denied = []
for i, arg in enumerate(args):
if arg in _DENIED_DOCKER_ARGS:
denied.append(arg)
elif (
arg in {"--network", "--net", "--pid", "--ipc"}
and i + 1 < len(args)
and args[i + 1] == "host"
):
denied.append(f"{arg} {args[i + 1]}")
if denied:
raise ValueError(
f"MCP stdio Docker args are unsafe and not allowed: {', '.join(denied)}."
)
def validate_mcp_stdio_config(config: dict) -> None:
"""Validate stdio MCP config before any subprocess can be spawned."""
cfg = _prepare_config(config.copy())
if "url" in cfg:
return
command = cfg.get("command")
if not isinstance(command, str) or not command.strip():
raise ValueError("MCP stdio server requires a non-empty command.")
if _SHELL_META_RE.search(command):
raise ValueError("MCP stdio command contains unsafe shell metacharacters.")
command_name = _normalize_stdio_command_name(command)
if command_name in _DENIED_STDIO_COMMANDS:
raise ValueError(f"MCP stdio command `{command_name}` is not allowed.")
allowed = _get_stdio_command_allowlist()
if command_name not in allowed:
allowed_display = ", ".join(sorted(allowed))
raise ValueError(
f"MCP stdio command `{command_name}` is not allowed. "
f"Allowed commands: {allowed_display}. "
f"Set {_STDIO_ALLOWLIST_ENV} to override this list if you trust another launcher."
)
_validate_stdio_args(command_name, cfg.get("args"))
env = cfg.get("env")
if env is not None and not isinstance(env, dict):
raise ValueError("MCP stdio env must be an object.")
if isinstance(env, dict) and not all(
isinstance(key, str) and isinstance(value, str) for key, value in env.items()
):
raise ValueError("MCP stdio env keys and values must be strings.")
def _prepare_stdio_env(config: dict) -> dict:
"""Preserve Windows executable resolution for stdio subprocesses."""
if sys.platform != "win32":
@@ -243,6 +432,7 @@ class MCPClient:
)
else:
validate_mcp_stdio_config(cfg)
cfg = _prepare_stdio_env(cfg)
server_params = mcp.StdioServerParameters(
**cfg,

View File

@@ -4,9 +4,11 @@ import sys
import time
import traceback
import typing as T
import uuid
from collections.abc import AsyncIterator
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
from mcp.types import (
BlobResourceContents,
@@ -25,7 +27,7 @@ from tenacity import (
from astrbot import logger
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
from astrbot.core.agent.tool import ToolSet
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.agent.tool_image_cache import tool_image_cache
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.message.components import Json
@@ -45,7 +47,7 @@ from astrbot.core.provider.provider import Provider
from ..context.compressor import ContextCompressor
from ..context.config import ContextConfig
from ..context.manager import ContextManager
from ..context.token_counter import TokenCounter
from ..context.token_counter import EstimateTokenCounter, TokenCounter
from ..hooks import BaseAgentRunHooks
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
from ..response import AgentResponseData, AgentStats
@@ -97,6 +99,8 @@ ToolExecutorResultT = T.TypeVar("ToolExecutorResultT")
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
TOOL_RESULT_MAX_ESTIMATED_TOKENS = 27_500
TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS = 7000
EMPTY_OUTPUT_RETRY_ATTEMPTS = 3
EMPTY_OUTPUT_RETRY_WAIT_MIN_S = 1
EMPTY_OUTPUT_RETRY_WAIT_MAX_S = 4
@@ -151,6 +155,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
"Otherwise, change strategy, adjust arguments, or explain the limitation "
"to the user."
)
TOOL_RESULT_OVERFLOW_NOTICE_TEMPLATE = (
"Truncated tool output preview shown above. "
"The tool output was too large to include directly and was written to "
"`{overflow_path}`. Use {read_tool_hint} to inspect it. "
"Use a narrower window when reading large files."
)
def _get_persona_custom_error_message(self) -> str | None:
"""Read persona-level custom error message from event extras when available."""
@@ -206,6 +216,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
custom_compressor: ContextCompressor | None = None,
tool_schema_mode: str | None = "full",
fallback_providers: list[Provider] | None = None,
tool_result_overflow_dir: str | None = None,
read_tool: FunctionTool | None = None,
**kwargs: T.Any,
) -> None:
self.req = request
@@ -217,6 +229,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.truncate_turns = truncate_turns
self.custom_token_counter = custom_token_counter
self.custom_compressor = custom_compressor
self.tool_result_overflow_dir = tool_result_overflow_dir
self.read_tool = read_tool
self._tool_result_token_counter = EstimateTokenCounter()
# we will do compress when:
# 1. before requesting LLM
# TODO: 2. after LLM output a tool call
@@ -298,6 +313,103 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.stats = AgentStats()
self.stats.start_time = time.time()
def _read_tool_hint(self) -> str:
if self.read_tool is not None:
return f"`{self.read_tool.name}`"
return "the available file-read tool"
async def _write_tool_result_overflow_file(
self,
*,
tool_call_id: str,
content: str,
) -> str:
if self.tool_result_overflow_dir is None:
raise ValueError("tool_result_overflow_dir is not configured")
overflow_dir = Path(self.tool_result_overflow_dir).resolve(strict=False)
safe_tool_call_id = (
"".join(
ch if ch.isalnum() or ch in {"-", "_", "."} else "_"
for ch in tool_call_id
).strip("._")
or "tool_call"
)
file_name = f"{safe_tool_call_id}_{uuid.uuid4().hex[:8]}.txt"
overflow_path = overflow_dir / file_name
def _run() -> str:
overflow_dir.mkdir(parents=True, exist_ok=True)
overflow_path.write_text(content, encoding="utf-8")
return str(overflow_path)
return await asyncio.to_thread(_run)
async def _materialize_large_tool_result(
self,
*,
tool_call_id: str,
content: str,
) -> str:
if self.tool_result_overflow_dir is None or self.read_tool is None:
return content
estimated_tokens = self._tool_result_token_counter.count_tokens(
[Message(role="tool", content=content, tool_call_id=tool_call_id)]
)
if estimated_tokens <= self.TOOL_RESULT_MAX_ESTIMATED_TOKENS:
return content
preview = self._truncate_tool_result_preview(content, tool_call_id=tool_call_id)
try:
overflow_path = await self._write_tool_result_overflow_file(
tool_call_id=tool_call_id,
content=content,
)
except Exception as exc:
logger.warning(
"Failed to spill oversized tool result for %s: %s",
tool_call_id,
exc,
exc_info=True,
)
error_notice = (
"Tool output exceeded the inline result limit "
f"({estimated_tokens} estimated tokens > "
f"{self.TOOL_RESULT_MAX_ESTIMATED_TOKENS}) and could not be written "
f"to `{self.tool_result_overflow_dir}`: {exc}"
)
if not preview:
return error_notice
return f"{preview}\n\n{error_notice}"
notice = self.TOOL_RESULT_OVERFLOW_NOTICE_TEMPLATE.format(
overflow_path=overflow_path,
read_tool_hint=self._read_tool_hint(),
)
if not preview:
return notice
return f"{preview}\n\n{notice}"
def _truncate_tool_result_preview(
self,
content: str,
*,
tool_call_id: str,
) -> str:
preview = content
while preview:
estimated_tokens = self._tool_result_token_counter.count_tokens(
[Message(role="tool", content=preview, tool_call_id=tool_call_id)]
)
if estimated_tokens <= self.TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS:
return preview
next_len = len(preview) // 2
if next_len <= 0:
break
preview = preview[:next_len]
return preview
async def _iter_llm_responses(
self, *, include_model: bool = True
) -> T.AsyncGenerator[LLMResponse, None]:
@@ -933,9 +1045,14 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
"The tool has returned a data type that is not supported."
)
if result_parts:
inline_result = "\n\n".join(result_parts)
inline_result = await self._materialize_large_tool_result(
tool_call_id=func_tool_id,
content=inline_result,
)
_append_tool_call_result(
func_tool_id,
"\n\n".join(result_parts)
inline_result
+ self._build_repeated_tool_call_guidance(
func_tool_name, tool_call_streak
),

View File

@@ -19,12 +19,6 @@ from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.astr_main_agent_resources import (
BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
EXECUTE_SHELL_TOOL,
FILE_DOWNLOAD_TOOL,
FILE_UPLOAD_TOOL,
LOCAL_EXECUTE_SHELL_TOOL,
LOCAL_PYTHON_TOOL,
PYTHON_TOOL,
)
from astrbot.core.cron.events import CronMessageEvent
from astrbot.core.message.components import Image
@@ -36,6 +30,17 @@ from astrbot.core.message.message_event_result import (
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.provider.entites import ProviderRequest
from astrbot.core.provider.register import llm_tools
from astrbot.core.tools.computer_tools import (
ExecuteShellTool,
FileDownloadTool,
FileEditTool,
FileReadTool,
FileUploadTool,
FileWriteTool,
GrepTool,
LocalPythonTool,
PythonTool,
)
from astrbot.core.tools.message_tools import SendMessageToUserTool
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.history_saver import persist_agent_history
@@ -177,18 +182,44 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
return
@classmethod
def _get_runtime_computer_tools(cls, runtime: str) -> dict[str, FunctionTool]:
def _get_runtime_computer_tools(
cls,
runtime: str,
tool_mgr,
) -> dict[str, FunctionTool]:
if runtime == "sandbox":
shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool)
python_tool = tool_mgr.get_builtin_tool(PythonTool)
upload_tool = tool_mgr.get_builtin_tool(FileUploadTool)
download_tool = tool_mgr.get_builtin_tool(FileDownloadTool)
read_tool = tool_mgr.get_builtin_tool(FileReadTool)
write_tool = tool_mgr.get_builtin_tool(FileWriteTool)
edit_tool = tool_mgr.get_builtin_tool(FileEditTool)
grep_tool = tool_mgr.get_builtin_tool(GrepTool)
return {
EXECUTE_SHELL_TOOL.name: EXECUTE_SHELL_TOOL,
PYTHON_TOOL.name: PYTHON_TOOL,
FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL,
FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL,
shell_tool.name: shell_tool,
python_tool.name: python_tool,
upload_tool.name: upload_tool,
download_tool.name: download_tool,
read_tool.name: read_tool,
write_tool.name: write_tool,
edit_tool.name: edit_tool,
grep_tool.name: grep_tool,
}
if runtime == "local":
shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool)
python_tool = tool_mgr.get_builtin_tool(LocalPythonTool)
read_tool = tool_mgr.get_builtin_tool(FileReadTool)
write_tool = tool_mgr.get_builtin_tool(FileWriteTool)
edit_tool = tool_mgr.get_builtin_tool(FileEditTool)
grep_tool = tool_mgr.get_builtin_tool(GrepTool)
return {
LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL,
LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL,
shell_tool.name: shell_tool,
python_tool.name: python_tool,
read_tool.name: read_tool,
write_tool.name: write_tool,
edit_tool.name: edit_tool,
grep_tool.name: grep_tool,
}
return {}
@@ -203,7 +234,15 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
cfg = ctx.get_config(umo=event.unified_msg_origin)
provider_settings = cfg.get("provider_settings", {})
runtime = str(provider_settings.get("computer_use_runtime", "local"))
runtime_computer_tools = cls._get_runtime_computer_tools(runtime)
tool_mgr = (
ctx.get_llm_tool_manager()
if hasattr(ctx, "get_llm_tool_manager")
else llm_tools
)
runtime_computer_tools = cls._get_runtime_computer_tools(
runtime,
tool_mgr,
)
# Keep persona semantics aligned with the main agent: tools=None means
# "all tools", including runtime computer-use tools.

View File

@@ -9,6 +9,7 @@ import platform
import zoneinfo
from collections.abc import Coroutine
from dataclasses import dataclass, field
from pathlib import Path
from astrbot.core import logger
from astrbot.core.agent.handoff import HandoffTool
@@ -20,30 +21,10 @@ from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
from astrbot.core.astr_agent_run_util import AgentRunner
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
from astrbot.core.astr_main_agent_resources import (
ANNOTATE_EXECUTION_TOOL,
BROWSER_BATCH_EXEC_TOOL,
BROWSER_EXEC_TOOL,
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
CREATE_SKILL_CANDIDATE_TOOL,
CREATE_SKILL_PAYLOAD_TOOL,
EVALUATE_SKILL_CANDIDATE_TOOL,
EXECUTE_SHELL_TOOL,
FILE_DOWNLOAD_TOOL,
FILE_UPLOAD_TOOL,
GET_EXECUTION_HISTORY_TOOL,
GET_SKILL_PAYLOAD_TOOL,
LIST_SKILL_CANDIDATES_TOOL,
LIST_SKILL_RELEASES_TOOL,
LIVE_MODE_SYSTEM_PROMPT,
LLM_SAFETY_MODE_SYSTEM_PROMPT,
LOCAL_EXECUTE_SHELL_TOOL,
LOCAL_PYTHON_TOOL,
PROMOTE_SKILL_CANDIDATE_TOOL,
PYTHON_TOOL,
ROLLBACK_SKILL_RELEASE_TOOL,
RUN_BROWSER_SKILL_TOOL,
SANDBOX_MODE_PROMPT,
SYNC_SKILL_RELEASE_TOOL,
TOOL_CALL_PROMPT,
TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
)
@@ -56,14 +37,37 @@ from astrbot.core.persona_error_reply import (
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.register 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
from astrbot.core.tools.cron_tools import (
CreateActiveCronTool,
DeleteCronJobTool,
ListCronJobsTool,
from astrbot.core.tools.computer_tools import (
AnnotateExecutionTool,
BrowserBatchExecTool,
BrowserExecTool,
CreateSkillCandidateTool,
CreateSkillPayloadTool,
EvaluateSkillCandidateTool,
ExecuteShellTool,
FileDownloadTool,
FileEditTool,
FileReadTool,
FileUploadTool,
FileWriteTool,
GetExecutionHistoryTool,
GetSkillPayloadTool,
GrepTool,
ListSkillCandidatesTool,
ListSkillReleasesTool,
LocalPythonTool,
PromoteSkillCandidateTool,
PythonTool,
RollbackSkillReleaseTool,
RunBrowserSkillTool,
SyncSkillReleaseTool,
normalize_umo_for_workspace,
)
from astrbot.core.tools.cron_tools import FutureTaskTool
from astrbot.core.tools.knowledge_base_tools import (
KnowledgeBaseQueryTool,
retrieve_knowledge_base,
@@ -77,6 +81,10 @@ from astrbot.core.tools.web_search_tools import (
TavilyWebSearchTool,
normalize_legacy_web_search_config,
)
from astrbot.core.utils.astrbot_path import (
get_astrbot_system_tmp_path,
get_astrbot_workspaces_path,
)
from astrbot.core.utils.file_extract import extract_file_moonshotai
from astrbot.core.utils.llm_metadata import LLM_METADATAS
from astrbot.core.utils.media_utils import (
@@ -294,11 +302,54 @@ def _apply_prompt_prefix(req: ProviderRequest, cfg: dict) -> None:
req.prompt = f"{prefix}{req.prompt}"
def _apply_local_env_tools(req: ProviderRequest) -> None:
def _get_workspace_path_for_umo(umo: str) -> Path:
normalized_umo = normalize_umo_for_workspace(umo)
return Path(get_astrbot_workspaces_path()) / normalized_umo
def _apply_workspace_extra_prompt(
event: AstrMessageEvent,
req: ProviderRequest,
) -> None:
extra_prompt_path = _get_workspace_path_for_umo(event.unified_msg_origin) / (
"EXTRA_PROMPT.md"
)
if not extra_prompt_path.is_file():
return
try:
extra_prompt = extra_prompt_path.read_text(encoding="utf-8").strip()
except Exception as exc: # noqa: BLE001
logger.warning(
"Failed to read workspace extra prompt for umo=%s from %s: %s",
event.unified_msg_origin,
extra_prompt_path,
exc,
)
return
if not extra_prompt:
return
req.system_prompt = (
f"{req.system_prompt or ''}\n"
"[Workspace Extra Prompt]\n"
"The following instructions are loaded from the current workspace "
"`EXTRA_PROMPT.md` file.\n"
f"{extra_prompt}\n"
)
def _apply_local_env_tools(req: ProviderRequest, plugin_context: Context) -> None:
if req.func_tool is None:
req.func_tool = ToolSet()
req.func_tool.add_tool(LOCAL_EXECUTE_SHELL_TOOL)
req.func_tool.add_tool(LOCAL_PYTHON_TOOL)
tool_mgr = plugin_context.get_llm_tool_manager()
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExecuteShellTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(LocalPythonTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileReadTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileWriteTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileEditTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(GrepTool))
req.system_prompt = f"{req.system_prompt or ''}\n{_build_local_mode_prompt()}\n"
@@ -769,6 +820,7 @@ async def _decorate_llm_request(
if tz is None:
tz = plugin_context.get_config().get("timezone")
_append_system_reminders(event, req, cfg, tz)
_apply_workspace_extra_prompt(event, req)
def _modalities_fix(provider: Provider, req: ProviderRequest) -> None:
@@ -985,7 +1037,9 @@ def _apply_llm_safety_mode(config: MainAgentBuildConfig, req: ProviderRequest) -
def _apply_sandbox_tools(
config: MainAgentBuildConfig, req: ProviderRequest, session_id: str
config: MainAgentBuildConfig,
req: ProviderRequest,
session_id: str,
) -> None:
if req.func_tool is None:
req.func_tool = ToolSet()
@@ -1001,10 +1055,15 @@ def _apply_sandbox_tools(
os.environ["SHIPYARD_ENDPOINT"] = ep
os.environ["SHIPYARD_ACCESS_TOKEN"] = at
req.func_tool.add_tool(EXECUTE_SHELL_TOOL)
req.func_tool.add_tool(PYTHON_TOOL)
req.func_tool.add_tool(FILE_UPLOAD_TOOL)
req.func_tool.add_tool(FILE_DOWNLOAD_TOOL)
tool_mgr = llm_tools
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExecuteShellTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(PythonTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileUploadTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileDownloadTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileReadTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileWriteTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileEditTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(GrepTool))
if booter == "shipyard_neo":
# Neo-specific path rule: filesystem tools operate relative to sandbox
# workspace root. Do not prepend "/workspace".
@@ -1040,22 +1099,22 @@ def _apply_sandbox_tools(
# Browser tools: only register if profile supports browser
# (or if capabilities are unknown because sandbox hasn't booted yet)
if sandbox_capabilities is None or "browser" in sandbox_capabilities:
req.func_tool.add_tool(BROWSER_EXEC_TOOL)
req.func_tool.add_tool(BROWSER_BATCH_EXEC_TOOL)
req.func_tool.add_tool(RUN_BROWSER_SKILL_TOOL)
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BrowserExecTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BrowserBatchExecTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(RunBrowserSkillTool))
# Neo-specific tools (always available for shipyard_neo)
req.func_tool.add_tool(GET_EXECUTION_HISTORY_TOOL)
req.func_tool.add_tool(ANNOTATE_EXECUTION_TOOL)
req.func_tool.add_tool(CREATE_SKILL_PAYLOAD_TOOL)
req.func_tool.add_tool(GET_SKILL_PAYLOAD_TOOL)
req.func_tool.add_tool(CREATE_SKILL_CANDIDATE_TOOL)
req.func_tool.add_tool(LIST_SKILL_CANDIDATES_TOOL)
req.func_tool.add_tool(EVALUATE_SKILL_CANDIDATE_TOOL)
req.func_tool.add_tool(PROMOTE_SKILL_CANDIDATE_TOOL)
req.func_tool.add_tool(LIST_SKILL_RELEASES_TOOL)
req.func_tool.add_tool(ROLLBACK_SKILL_RELEASE_TOOL)
req.func_tool.add_tool(SYNC_SKILL_RELEASE_TOOL)
req.func_tool.add_tool(tool_mgr.get_builtin_tool(GetExecutionHistoryTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(AnnotateExecutionTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(CreateSkillPayloadTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(GetSkillPayloadTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(CreateSkillCandidateTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ListSkillCandidatesTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(EvaluateSkillCandidateTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(PromoteSkillCandidateTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ListSkillReleasesTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(RollbackSkillReleaseTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(SyncSkillReleaseTool))
req.system_prompt = f"{req.system_prompt or ''}\n{SANDBOX_MODE_PROMPT}\n"
@@ -1064,9 +1123,7 @@ def _proactive_cron_job_tools(req: ProviderRequest, plugin_context: Context) ->
if req.func_tool is None:
req.func_tool = ToolSet()
tool_mgr = plugin_context.get_llm_tool_manager()
req.func_tool.add_tool(tool_mgr.get_builtin_tool(CreateActiveCronTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(DeleteCronJobTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ListCronJobsTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FutureTaskTool))
async def _apply_web_search_tools(
@@ -1347,7 +1404,7 @@ async def build_main_agent(
if config.computer_use_runtime == "sandbox":
_apply_sandbox_tools(config, req, req.session_id)
elif config.computer_use_runtime == "local":
_apply_local_env_tools(req)
_apply_local_env_tools(req, plugin_context)
agent_runner = AgentRunner()
astr_agent_ctx = AstrAgentContext(
@@ -1383,6 +1440,15 @@ async def build_main_agent(
if config.tool_schema_mode == "full"
else TOOL_CALL_PROMPT_SKILLS_LIKE_MODE
)
if config.computer_use_runtime == "local":
tool_prompt += (
f"\nCurrent workspace you can use: "
f"`{_get_workspace_path_for_umo(event.unified_msg_origin)}`\n"
"Unless the user explicitly specifies a different directory, "
"perform all file-related operations in this workspace.\n"
)
req.system_prompt += f"\n{tool_prompt}\n"
action_type = event.get_extra("action_type")
@@ -1408,6 +1474,14 @@ async def build_main_agent(
fallback_providers=_get_fallback_chat_providers(
provider, plugin_context, config.provider_settings
),
tool_result_overflow_dir=(
get_astrbot_system_tmp_path()
if req.func_tool and req.func_tool.get_tool("astrbot_file_read_tool")
else None
),
read_tool=(
req.func_tool.get_tool("astrbot_file_read_tool") if req.func_tool else None
),
)
if apply_reset:

View File

@@ -1,27 +1,5 @@
import base64
from astrbot.core.computer.tools import (
AnnotateExecutionTool,
BrowserBatchExecTool,
BrowserExecTool,
CreateSkillCandidateTool,
CreateSkillPayloadTool,
EvaluateSkillCandidateTool,
ExecuteShellTool,
FileDownloadTool,
FileUploadTool,
GetExecutionHistoryTool,
GetSkillPayloadTool,
ListSkillCandidatesTool,
ListSkillReleasesTool,
LocalPythonTool,
PromoteSkillCandidateTool,
PythonTool,
RollbackSkillReleaseTool,
RunBrowserSkillTool,
SyncSkillReleaseTool,
)
LLM_SAFETY_MODE_SYSTEM_PROMPT = """You are running in Safe Mode.
Rules:
@@ -130,28 +108,6 @@ BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT = (
"{background_task_result}"
)
EXECUTE_SHELL_TOOL = ExecuteShellTool()
LOCAL_EXECUTE_SHELL_TOOL = ExecuteShellTool(is_local=True)
PYTHON_TOOL = PythonTool()
LOCAL_PYTHON_TOOL = LocalPythonTool()
FILE_UPLOAD_TOOL = FileUploadTool()
FILE_DOWNLOAD_TOOL = FileDownloadTool()
BROWSER_EXEC_TOOL = BrowserExecTool()
BROWSER_BATCH_EXEC_TOOL = BrowserBatchExecTool()
RUN_BROWSER_SKILL_TOOL = RunBrowserSkillTool()
GET_EXECUTION_HISTORY_TOOL = GetExecutionHistoryTool()
ANNOTATE_EXECUTION_TOOL = AnnotateExecutionTool()
CREATE_SKILL_PAYLOAD_TOOL = CreateSkillPayloadTool()
GET_SKILL_PAYLOAD_TOOL = GetSkillPayloadTool()
CREATE_SKILL_CANDIDATE_TOOL = CreateSkillCandidateTool()
LIST_SKILL_CANDIDATES_TOOL = ListSkillCandidatesTool()
EVALUATE_SKILL_CANDIDATE_TOOL = EvaluateSkillCandidateTool()
PROMOTE_SKILL_CANDIDATE_TOOL = PromoteSkillCandidateTool()
LIST_SKILL_RELEASES_TOOL = ListSkillReleasesTool()
ROLLBACK_SKILL_RELEASE_TOOL = RollbackSkillReleaseTool()
SYNC_SKILL_RELEASE_TOOL = SyncSkillReleaseTool()
# we prevent astrbot from connecting to known malicious hosts
# these hosts are base64 encoded
BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}

View File

@@ -4,7 +4,7 @@ from typing import Any
import aiohttp
import boxlite
from shipyard.filesystem import FileSystemComponent as ShipyardFileSystemComponent
from shipyard import FileSystemComponent as ShipyardFileSystemComponent
from shipyard.python import PythonComponent as ShipyardPythonComponent
from shipyard.shell import ShellComponent as ShipyardShellComponent
@@ -12,6 +12,7 @@ from astrbot.api import logger
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
from .shipyard import ShipyardFileSystemWrapper
class MockShipyardSandboxClient:
@@ -150,11 +151,6 @@ class BoxliteBooter(ComputerBooter):
self.mocked = MockShipyardSandboxClient(
sb_url=f"http://127.0.0.1:{random_port}"
)
self._fs = ShipyardFileSystemComponent(
client=self.mocked, # type: ignore
ship_id=self.box.id,
session_id=session_id,
)
self._python = ShipyardPythonComponent(
client=self.mocked, # type: ignore
ship_id=self.box.id,
@@ -165,6 +161,14 @@ class BoxliteBooter(ComputerBooter):
ship_id=self.box.id,
session_id=session_id,
)
self._ship_fs = ShipyardFileSystemComponent(
client=self.mocked, # type: ignore
ship_id=self.box.id,
session_id=session_id,
)
self._fs = ShipyardFileSystemWrapper(
_shipyard_fs=self._ship_fs, _shipyard_shell=self._shell
)
await self.mocked.wait_healthy(self.box.id, session_id)

View File

@@ -9,15 +9,18 @@ import sys
from dataclasses import dataclass
from typing import Any
from python_ripgrep import search
from astrbot.api import logger
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_root,
get_astrbot_temp_path,
from astrbot.core.computer.file_read_utils import (
detect_text_encoding,
read_local_text_range_sync,
)
from astrbot.core.utils.astrbot_path import get_astrbot_root
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
from .shipyard_search_file_util import _truncate_long_lines
_BLOCKED_COMMAND_PATTERNS = [
" rm -rf ",
@@ -41,18 +44,6 @@ def _is_safe_command(command: str) -> bool:
return not any(pat in cmd for pat in _BLOCKED_COMMAND_PATTERNS)
def _ensure_safe_path(path: str) -> str:
abs_path = os.path.abspath(path)
allowed_roots = [
os.path.abspath(get_astrbot_root()),
os.path.abspath(get_astrbot_data_path()),
os.path.abspath(get_astrbot_temp_path()),
]
if not any(abs_path.startswith(root) for root in allowed_roots):
raise PermissionError("Path is outside the allowed computer roots.")
return abs_path
def _decode_bytes_with_fallback(
output: bytes | None,
*,
@@ -110,7 +101,7 @@ class LocalShellComponent(ShellComponent):
run_env = os.environ.copy()
if env:
run_env.update({str(k): str(v) for k, v in env.items()})
working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
working_dir = os.path.abspath(cwd) if cwd else get_astrbot_root()
if background:
# `command` is intentionally executed through the current shell so
# local computer-use behavior matches existing tool semantics.
@@ -186,7 +177,7 @@ class LocalFileSystemComponent(FileSystemComponent):
self, path: str, content: str = "", mode: int = 0o644
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
abs_path = os.path.abspath(path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, "w", encoding="utf-8") as f:
f.write(content)
@@ -195,16 +186,85 @@ class LocalFileSystemComponent(FileSystemComponent):
return await asyncio.to_thread(_run)
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
async def read_file(
self,
path: str,
encoding: str = "utf-8",
offset: int | None = None,
limit: int | None = None,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
with open(abs_path, "rb") as f:
raw_content = f.read()
content = _decode_bytes_with_fallback(
raw_content,
preferred_encoding=encoding,
abs_path = os.path.abspath(path)
detected_encoding = encoding
if encoding == "utf-8":
with open(abs_path, "rb") as f:
raw_sample = f.read(8192)
detected_encoding = detect_text_encoding(raw_sample) or encoding
return {
"success": True,
"content": read_local_text_range_sync(
abs_path,
encoding=detected_encoding,
offset=offset,
limit=limit,
),
}
return await asyncio.to_thread(_run)
async def search_files(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
after_context: int | None = None,
before_context: int | None = None,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
results = search(
patterns=[pattern],
paths=[path] if path else None,
globs=[glob] if glob else None,
after_context=after_context,
before_context=before_context,
line_number=True,
)
return {"success": True, "content": content}
return {"success": True, "content": _truncate_long_lines("".join(results))}
return await asyncio.to_thread(_run)
async def edit_file(
self,
path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
encoding: str = "utf-8",
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = os.path.abspath(path)
with open(abs_path, encoding=encoding) as f:
content = f.read()
occurrences = content.count(old_string)
if occurrences == 0:
return {
"success": False,
"error": "old string not found in file",
"replacements": 0,
}
if replace_all:
updated = content.replace(old_string, new_string)
replacements = occurrences
else:
updated = content.replace(old_string, new_string, 1)
replacements = 1
with open(abs_path, "w", encoding=encoding) as f:
f.write(updated)
return {
"success": True,
"path": abs_path,
"replacements": replacements,
}
return await asyncio.to_thread(_run)
@@ -212,7 +272,7 @@ class LocalFileSystemComponent(FileSystemComponent):
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
abs_path = os.path.abspath(path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, mode, encoding=encoding) as f:
f.write(content)
@@ -222,7 +282,7 @@ class LocalFileSystemComponent(FileSystemComponent):
async def delete_file(self, path: str) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
abs_path = os.path.abspath(path)
if os.path.isdir(abs_path):
shutil.rmtree(abs_path)
else:
@@ -235,7 +295,7 @@ class LocalFileSystemComponent(FileSystemComponent):
self, path: str = ".", show_hidden: bool = False
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
abs_path = os.path.abspath(path)
entries = os.listdir(abs_path)
if not show_hidden:
entries = [e for e in entries if not e.startswith(".")]

View File

@@ -1,9 +1,87 @@
from __future__ import annotations
from typing import Any
from shipyard import FileSystemComponent as ShipyardFileSystemComponent
from shipyard import ShipyardClient, Spec
from astrbot.api import logger
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
from .shipyard_search_file_util import search_files_via_shell
class ShipyardFileSystemWrapper:
def __init__(
self, _shipyard_fs: ShipyardFileSystemComponent, _shipyard_shell: ShellComponent
):
self._fs = _shipyard_fs
self._shell = _shipyard_shell
async def create_file(
self, path: str, content: str = "", mode: int = 420
) -> dict[str, Any]:
return await self._fs.create_file(path=path, content=content, mode=mode)
async def read_file(
self,
path: str,
encoding: str = "utf-8",
offset: int | None = None,
limit: int | None = None,
) -> dict[str, Any]:
return await self._fs.read_file(
path=path, encoding=encoding, offset=offset, limit=limit
)
async def write_file(
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
) -> dict[str, Any]:
return await self._fs.write_file(
path=path, content=content, mode=mode, encoding=encoding
)
async def list_dir(
self, path: str = ".", show_hidden: bool = False
) -> dict[str, Any]:
return await self._fs.list_dir(path=path, show_hidden=show_hidden)
async def delete_file(self, path: str) -> dict[str, Any]:
return await self._fs.delete_file(path=path)
async def search_files(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
after_context: int | None = None,
before_context: int | None = None,
) -> dict[str, Any]:
return await search_files_via_shell(
self._shell,
pattern=pattern,
path=path,
glob=glob,
after_context=after_context,
before_context=before_context,
)
async def edit_file(
self,
path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
encoding: str = "utf-8",
) -> dict[str, Any]:
return await self._fs.edit_file(
path=path,
old_string=old_string,
new_string=new_string,
replace_all=replace_all,
encoding=encoding,
)
class ShipyardBooter(ComputerBooter):
@@ -29,13 +107,14 @@ class ShipyardBooter(ComputerBooter):
)
logger.info(f"Got sandbox ship: {ship.id} for session: {session_id}")
self._ship = ship
self._fs = ShipyardFileSystemWrapper(self._ship.fs, self._ship.shell)
async def shutdown(self) -> None:
logger.info("[Computer] Shipyard booter shutdown.")
@property
def fs(self) -> FileSystemComponent:
return self._ship.fs
return self._fs
@property
def python(self) -> PythonComponent:

View File

@@ -13,6 +13,15 @@ from ..olayer import (
ShellComponent,
)
from .base import ComputerBooter
from .shipyard_search_file_util import search_files_via_shell
try:
from shipyard_neo import BayClient
from shipyard_neo.sandbox import Sandbox
except ImportError:
logger.warning(
"shipyard_neo_sdk is not installed. ShipyardNeoBooter will not work without it."
)
def _maybe_model_dump(value: Any) -> dict[str, Any]:
@@ -25,8 +34,20 @@ def _maybe_model_dump(value: Any) -> dict[str, Any]:
return {}
def _slice_content_by_lines(
content: str,
*,
offset: int | None = None,
limit: int | None = None,
) -> str:
lines = content.splitlines(keepends=True)
start = 0 if offset is None else offset
selected = lines[start:] if limit is None else lines[start : start + limit]
return "".join(selected)
class NeoPythonComponent(PythonComponent):
def __init__(self, sandbox: Any) -> None:
def __init__(self, sandbox: Sandbox) -> None:
self._sandbox = sandbox
async def exec(
@@ -67,7 +88,7 @@ class NeoPythonComponent(PythonComponent):
class NeoShellComponent(ShellComponent):
def __init__(self, sandbox: Any) -> None:
def __init__(self, sandbox: Sandbox) -> None:
self._sandbox = sandbox
async def exec(
@@ -136,8 +157,9 @@ class NeoShellComponent(ShellComponent):
class NeoFileSystemComponent(FileSystemComponent):
def __init__(self, sandbox: Any) -> None:
def __init__(self, sandbox: Sandbox, shell: ShellComponent) -> None:
self._sandbox = sandbox
self._shell = shell
async def create_file(
self,
@@ -149,10 +171,71 @@ class NeoFileSystemComponent(FileSystemComponent):
await self._sandbox.filesystem.write_file(path, content)
return {"success": True, "path": path}
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
async def read_file(
self,
path: str,
encoding: str = "utf-8",
offset: int | None = None,
limit: int | None = None,
) -> dict[str, Any]:
_ = encoding
content = await self._sandbox.filesystem.read_file(path)
return {"success": True, "path": path, "content": content}
return {
"success": True,
"path": path,
"content": _slice_content_by_lines(
content,
offset=offset,
limit=limit,
),
}
async def search_files(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
after_context: int | None = None,
before_context: int | None = None,
) -> dict[str, Any]:
return await search_files_via_shell(
self._shell,
pattern=pattern,
path=path,
glob=glob,
after_context=after_context,
before_context=before_context,
)
async def edit_file(
self,
path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
encoding: str = "utf-8",
) -> dict[str, Any]:
_ = encoding
content = await self._sandbox.filesystem.read_file(path)
occurrences = content.count(old_string)
if occurrences == 0:
return {
"success": False,
"error": "old string not found in file",
"replacements": 0,
}
if replace_all:
updated = content.replace(old_string, new_string)
replacements = occurrences
else:
updated = content.replace(old_string, new_string, 1)
replacements = 1
await self._sandbox.filesystem.write_file(path, updated)
return {
"success": True,
"path": path,
"replacements": replacements,
}
async def write_file(
self,
@@ -186,7 +269,7 @@ class NeoFileSystemComponent(FileSystemComponent):
class NeoBrowserComponent(BrowserComponent):
def __init__(self, sandbox: Any) -> None:
def __init__(self, sandbox: Sandbox) -> None:
self._sandbox = sandbox
async def exec(
@@ -271,8 +354,8 @@ class ShipyardNeoBooter(ComputerBooter):
self._access_token = access_token
self._profile = profile
self._ttl = ttl
self._client: Any = None
self._sandbox: Any = None
self._client: BayClient | None = None
self._sandbox: Sandbox | None = None
self._bay_manager: Any = None # BayContainerManager when auto-started
self._fs: FileSystemComponent | None = None
self._python: PythonComponent | None = None
@@ -336,8 +419,6 @@ class ShipyardNeoBooter(ComputerBooter):
"or ensure Bay's credentials.json is accessible for auto-discovery."
)
from shipyard_neo import BayClient
self._client = BayClient(
endpoint_url=self._endpoint_url,
access_token=self._access_token,
@@ -352,9 +433,9 @@ class ShipyardNeoBooter(ComputerBooter):
ttl=self._ttl,
)
self._fs = NeoFileSystemComponent(self._sandbox)
self._python = NeoPythonComponent(self._sandbox)
self._shell = NeoShellComponent(self._sandbox)
self._fs = NeoFileSystemComponent(self._sandbox, self._shell)
self._python = NeoPythonComponent(self._sandbox)
caps = self.capabilities or ()
self._browser = (

View File

@@ -0,0 +1,148 @@
from __future__ import annotations
import shlex
from typing import Any
from ..olayer import ShellComponent
_MAX_SEARCH_LINE_COLUMNS = 1000
def _truncate_long_lines(text: str) -> str:
output_lines: list[str] = []
for line in text.splitlines(keepends=True):
line_ending = ""
line_body = line
if line.endswith("\r\n"):
line_body = line[:-2]
line_ending = "\r\n"
elif line.endswith("\n") or line.endswith("\r"):
line_body = line[:-1]
line_ending = line[-1]
if len(line_body) > _MAX_SEARCH_LINE_COLUMNS:
line_body = line_body[:_MAX_SEARCH_LINE_COLUMNS]
output_lines.append(f"{line_body}{line_ending}")
return "".join(output_lines)
def _build_rg_command(
*,
pattern: str,
path: str,
glob: str | None,
after_context: int | None,
before_context: int | None,
) -> list[str]:
command = [
"rg",
"--color=never",
"-n",
"--max-columns",
str(_MAX_SEARCH_LINE_COLUMNS),
"-e",
pattern,
]
if glob:
command.extend(["-g", glob])
if after_context is not None:
command.extend(["-A", str(after_context)])
if before_context is not None:
command.extend(["-B", str(before_context)])
command.extend(["--", path])
return command
def _build_grep_command(
*,
pattern: str,
path: str,
glob: str | None,
after_context: int | None,
before_context: int | None,
) -> list[str]:
command = ["grep", "-R", "-H", "-n", "-e", pattern]
if glob:
command.append(f"--include={glob}")
if after_context is not None:
command.extend(["-A", str(after_context)])
if before_context is not None:
command.extend(["-B", str(before_context)])
command.extend(["--", path])
return command
def _quote_command(command: list[str]) -> str:
return " ".join(shlex.quote(part) for part in command)
def build_search_command(
*,
pattern: str,
path: str,
glob: str | None,
after_context: int | None,
before_context: int | None,
) -> str:
rg_command = _quote_command(
_build_rg_command(
pattern=pattern,
path=path,
glob=glob,
after_context=after_context,
before_context=before_context,
)
)
grep_command = _quote_command(
_build_grep_command(
pattern=pattern,
path=path,
glob=glob,
after_context=after_context,
before_context=before_context,
)
)
return (
"if command -v rg >/dev/null 2>&1; then "
f"{rg_command}; "
"elif command -v grep >/dev/null 2>&1; then "
f"{grep_command}; "
"else "
"echo 'Neither rg nor grep is available in the sandbox.' >&2; "
"exit 127; "
"fi"
)
async def search_files_via_shell(
shell: ShellComponent,
*,
pattern: str,
path: str | None = None,
glob: str | None = None,
after_context: int | None = None,
before_context: int | None = None,
timeout: int = 30,
) -> dict[str, Any]:
command = build_search_command(
pattern=pattern,
path=path or ".",
glob=glob,
after_context=after_context,
before_context=before_context,
)
result = await shell.exec(command, timeout=timeout)
stdout = _truncate_long_lines(str(result.get("stdout", "") or ""))
stderr = str(result.get("stderr", "") or "")
exit_code = result.get("exit_code")
if exit_code in (0, None):
return {"success": True, "content": stdout}
if exit_code == 1:
return {"success": True, "content": ""}
return {
"success": False,
"content": "",
"error": stderr or f"command exited with code {exit_code}",
"exit_code": exit_code,
}

View File

@@ -0,0 +1,707 @@
from __future__ import annotations
import base64
import hashlib
import io
import json
import zipfile
from asyncio import to_thread
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
import mcp
from astrbot.core.agent.context.token_counter import EstimateTokenCounter
from astrbot.core.agent.message import Message
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.media_utils import (
IMAGE_COMPRESS_DEFAULT_MAX_SIZE,
IMAGE_COMPRESS_DEFAULT_OPTIMIZE,
IMAGE_COMPRESS_DEFAULT_QUALITY,
_compress_image_sync,
)
from .booters.base import ComputerBooter
_MAX_FILE_READ_BYTES = 128 * 1024
_MAX_FILE_READ_TOKENS = 25_000
_MAX_TEXT_FILE_FULL_READ_BYTES = 256 * 1024
_FILE_SNIFF_BYTES = 512
_TOKEN_COUNTER = EstimateTokenCounter()
_TEXT_ENCODINGS = (
"utf-8-sig",
"utf-8",
"gb18030",
"utf-16",
"utf-16-le",
"utf-16-be",
"utf-32",
"utf-32-le",
"utf-32-be",
)
_UTF_BOMS = (
b"\xef\xbb\xbf",
b"\xff\xfe",
b"\xfe\xff",
b"\xff\xfe\x00\x00",
b"\x00\x00\xfe\xff",
)
_ZIP_MAGIC_PREFIXES = (
b"PK\x03\x04",
b"PK\x05\x06",
b"PK\x07\x08",
)
_BINARY_MAGIC_PREFIXES = (
b"%PDF-",
b"\x1f\x8b",
b"7z\xbc\xaf\x27\x1c",
b"Rar!\x1a\x07",
b"\x7fELF",
b"MZ",
)
@dataclass(frozen=True)
class FileProbe:
kind: Literal["text", "image", "binary"]
encoding: str | None
mime_type: str | None
size_bytes: int
@dataclass(frozen=True)
class ParsedDocument:
kind: Literal["docx", "pdf"]
file_bytes: bytes
text: str
def _build_probe_script(path: str) -> str:
return f"""
import base64
import json
from pathlib import Path
path = Path({path!r})
with path.open("rb") as file_obj:
sample = file_obj.read({_FILE_SNIFF_BYTES})
print(
json.dumps(
{{
"size_bytes": path.stat().st_size,
"sample_b64": base64.b64encode(sample).decode("ascii"),
}}
)
)
""".strip()
def _build_text_read_script(
path: str,
*,
encoding: str,
offset: int | None,
limit: int | None,
) -> str:
start_expr = "0" if offset is None else str(offset)
limit_expr = "None" if limit is None else str(limit)
return f"""
import json
from pathlib import Path
path = Path({path!r})
start = {start_expr}
limit = {limit_expr}
end = None if limit is None else start + limit
lines = []
with path.open("r", encoding={encoding!r}, newline="") as file_obj:
for index, line in enumerate(file_obj):
if index < start:
continue
if end is not None and index >= end:
break
lines.append(line)
content = "".join(lines)
print(json.dumps({{"content": content}}, ensure_ascii=False))
""".strip()
def _build_image_read_script(path: str) -> str:
return f"""
import base64
import json
from pathlib import Path
path = Path({path!r})
data = path.read_bytes()
print(
json.dumps(
{{
"size_bytes": len(data),
"base64": base64.b64encode(data).decode("ascii"),
}}
)
)
""".strip()
def _looks_like_text(decoded: str) -> bool:
if not decoded:
return True
disallowed = 0
printable = 0
for char in decoded:
if char in "\n\r\t\f\b":
printable += 1
continue
if char.isprintable():
printable += 1
code = ord(char)
if (0 <= code < 32) or (127 <= code < 160):
disallowed += 1
total = max(len(decoded), 1)
return disallowed / total <= 0.02 and printable / total >= 0.85
def detect_text_encoding(sample: bytes) -> str | None:
if not sample:
return "utf-8"
if b"\x00" in sample and not sample.startswith(_UTF_BOMS):
odd_bytes = sample[1::2]
even_bytes = sample[0::2]
odd_zero_ratio = odd_bytes.count(0) / max(len(odd_bytes), 1)
even_zero_ratio = even_bytes.count(0) / max(len(even_bytes), 1)
if odd_zero_ratio < 0.8 and even_zero_ratio < 0.8:
return None
for encoding in _TEXT_ENCODINGS:
try:
decoded = sample.decode(encoding)
except UnicodeDecodeError as exc:
# Probe samples can end in the middle of a multibyte sequence.
# When the decode failure only happens at the sample tail, trim a few
# bytes and retry so UTF-8 text is not misclassified as binary.
if exc.start >= len(sample) - 4:
decoded = ""
for trim_bytes in range(1, min(4, len(sample)) + 1):
try:
decoded = sample[:-trim_bytes].decode(encoding)
break
except UnicodeDecodeError:
continue
if not decoded:
continue
else:
continue
if _looks_like_text(decoded):
return encoding
return None
def read_local_text_range_sync(
path: str,
*,
encoding: str,
offset: int | None,
limit: int | None,
) -> str:
lines: list[str] = []
start = 0 if offset is None else offset
end = None if limit is None else start + limit
with open(path, encoding=encoding, newline="") as file_obj:
for index, line in enumerate(file_obj):
if index < start:
continue
if end is not None and index >= end:
break
lines.append(line)
return "".join(lines)
async def read_local_text_range(
path: str,
*,
encoding: str,
offset: int | None,
limit: int | None,
) -> str:
return await to_thread(
read_local_text_range_sync,
path,
encoding=encoding,
offset=offset,
limit=limit,
)
async def _exec_python_json(
booter: ComputerBooter,
script: str,
*,
action: str,
) -> dict:
result = await booter.python.exec(script)
data = result.get("data") if isinstance(result.get("data"), dict) else {}
if not isinstance(data, dict):
raise RuntimeError(f"{action} failed: invalid result format")
output = data.get("output") if isinstance(data.get("output"), dict) else {}
if not isinstance(output, dict):
raise RuntimeError(f"{action} failed: invalid output format")
error_text = str(data.get("error", "") or result.get("error", "") or "").strip()
if error_text:
raise RuntimeError(f"{action} failed: {error_text}")
text = str(output.get("text", "") or "").strip()
if not text:
raise RuntimeError(f"{action} failed: empty output")
try:
payload = json.loads(text)
except json.JSONDecodeError as exc:
raise RuntimeError(f"{action} failed: invalid JSON output") from exc
if not isinstance(payload, dict):
raise RuntimeError(f"{action} failed: invalid JSON payload")
return payload
async def _probe_local_file(path: str) -> dict[str, str | int]:
def _run() -> dict[str, str | int]:
file_path = Path(path)
with file_path.open("rb") as file_obj:
sample = file_obj.read(_FILE_SNIFF_BYTES)
return {
"size_bytes": file_path.stat().st_size,
"sample_b64": base64.b64encode(sample).decode("ascii"),
}
return await to_thread(_run)
async def _read_local_image_base64(path: str) -> dict[str, str | int]:
def _run() -> dict[str, str | int]:
data = Path(path).read_bytes()
return {
"size_bytes": len(data),
"base64": base64.b64encode(data).decode("ascii"),
}
return await to_thread(_run)
async def _read_local_file_bytes(path: str) -> bytes:
return await to_thread(Path(path).read_bytes)
async def _compress_image_bytes_to_base64(data: bytes) -> dict[str, str | int]:
def _run() -> dict[str, str | int]:
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
compressed_path = Path(
_compress_image_sync(
data,
temp_dir,
IMAGE_COMPRESS_DEFAULT_MAX_SIZE,
IMAGE_COMPRESS_DEFAULT_QUALITY,
IMAGE_COMPRESS_DEFAULT_OPTIMIZE,
)
)
try:
compressed_bytes = compressed_path.read_bytes()
finally:
compressed_path.unlink(missing_ok=True)
return {
"size_bytes": len(compressed_bytes),
"base64": base64.b64encode(compressed_bytes).decode("ascii"),
"mime_type": "image/jpeg",
}
return await to_thread(_run)
def _detect_image_mime(sample: bytes) -> str | None:
if sample.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if sample.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if sample.startswith((b"GIF87a", b"GIF89a")):
return "image/gif"
if sample.startswith(b"BM"):
return "image/bmp"
if sample.startswith((b"II*\x00", b"MM\x00*")):
return "image/tiff"
if sample.startswith(b"\x00\x00\x01\x00"):
return "image/x-icon"
if len(sample) >= 12 and sample[:4] == b"RIFF" and sample[8:12] == b"WEBP":
return "image/webp"
if len(sample) >= 12 and sample[4:12] in (b"ftypavif", b"ftypavis"):
return "image/avif"
return None
def _looks_like_known_binary(sample: bytes) -> bool:
return any(sample.startswith(prefix) for prefix in _BINARY_MAGIC_PREFIXES)
def _looks_like_pdf(path: str, sample: bytes) -> bool:
return Path(path).suffix.lower() == ".pdf" or sample.startswith(b"%PDF-")
def _looks_like_zip_container(sample: bytes) -> bool:
return any(sample.startswith(prefix) for prefix in _ZIP_MAGIC_PREFIXES)
def _is_docx_bytes(file_bytes: bytes) -> bool:
try:
with zipfile.ZipFile(io.BytesIO(file_bytes)) as archive:
names = set(archive.namelist())
except (OSError, zipfile.BadZipFile):
return False
if "[Content_Types].xml" not in names:
return False
return any(name.startswith("word/") for name in names)
async def _parse_local_docx_text(file_bytes: bytes, file_name: str) -> str:
from astrbot.core.knowledge_base.parsers.markitdown_parser import (
MarkitdownParser,
)
result = await MarkitdownParser().parse(file_bytes, file_name)
return result.text
async def _parse_local_pdf_text(file_bytes: bytes, file_name: str) -> str:
from astrbot.core.knowledge_base.parsers.pdf_parser import PDFParser
result = await PDFParser().parse(file_bytes, file_name)
return result.text
async def _parse_local_supported_document(
path: str,
sample: bytes,
) -> ParsedDocument | None:
file_name = Path(path).name
if _looks_like_pdf(path, sample):
file_bytes = await _read_local_file_bytes(path)
text = await _parse_local_pdf_text(file_bytes, file_name)
return ParsedDocument(kind="pdf", file_bytes=file_bytes, text=text)
if Path(path).suffix.lower() == ".docx" or _looks_like_zip_container(sample):
file_bytes = await _read_local_file_bytes(path)
if not _is_docx_bytes(file_bytes):
return None
text = await _parse_local_docx_text(file_bytes, file_name)
return ParsedDocument(kind="docx", file_bytes=file_bytes, text=text)
return None
def _probe_file(sample: bytes, *, size_bytes: int) -> FileProbe:
if image_mime := _detect_image_mime(sample):
return FileProbe(
kind="image",
encoding=None,
mime_type=image_mime,
size_bytes=size_bytes,
)
if _looks_like_known_binary(sample):
return FileProbe(
kind="binary",
encoding=None,
mime_type=None,
size_bytes=size_bytes,
)
if encoding := detect_text_encoding(sample):
return FileProbe(
kind="text",
encoding=encoding,
mime_type="text/plain",
size_bytes=size_bytes,
)
return FileProbe(
kind="binary",
encoding=None,
mime_type=None,
size_bytes=size_bytes,
)
def _validate_text_output(content: str) -> str | None:
content_bytes = len(content.encode("utf-8"))
if content_bytes > _MAX_FILE_READ_BYTES:
return (
"Error reading file: "
f"output exceeds {_MAX_FILE_READ_BYTES} bytes "
f"({content_bytes} bytes). Use `offset`, `limit` to narrow the read window."
)
content_tokens = _TOKEN_COUNTER.count_tokens(
[Message(role="user", content=content)]
)
if content_tokens > _MAX_FILE_READ_TOKENS:
return (
"Error reading file: "
f"output exceeds {_MAX_FILE_READ_TOKENS} tokens "
f"({content_tokens} tokens). Use `offset`, `limit` to narrow the read window."
)
return None
def _text_exceeds_read_thresholds(content: str) -> bool:
return _validate_text_output(content) is not None
def _validate_full_text_read_request(probe: FileProbe) -> str | None:
if probe.size_bytes > _MAX_TEXT_FILE_FULL_READ_BYTES:
return (
"Error reading file: "
f"text file exceeds {_MAX_TEXT_FILE_FULL_READ_BYTES} bytes "
f"({probe.size_bytes} bytes). Use `offset` and `limit` to narrow the read window."
)
return None
def _slice_text_by_lines(
content: str,
*,
offset: int | None,
limit: int | None,
) -> str:
if offset is None and limit is None:
return content
lines = content.splitlines(keepends=True)
start = 0 if offset is None else offset
end = None if limit is None else start + limit
return "".join(lines[start:end])
async def _store_converted_text_for_workspace(
*,
workspace_dir: str,
original_path: str,
original_bytes: bytes,
content: str,
) -> str:
def _run() -> str:
original_name = Path(original_path).name
digest_suffix = hashlib.md5(original_bytes).hexdigest()[-6:]
target_dir = (
Path(workspace_dir) / "converted_files" / f"{original_name}_{digest_suffix}"
)
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / "text.txt"
target_path.write_text(content, encoding="utf-8")
return str(target_path)
return await to_thread(_run)
def _build_converted_text_notice(
converted_text_path: str,
*,
selection_returned: bool,
selection_too_large: bool = False,
) -> str:
if selection_too_large:
return (
"Converted text was saved to "
f"`{converted_text_path}`. The requested output is still too large to "
"return directly. Read or grep that file with a narrower window."
)
if selection_returned:
return (
"Full converted text is also available at "
f"`{converted_text_path}`. Read or grep that file with a narrow "
"window for additional reads."
)
return (
"Converted text was saved to "
f"`{converted_text_path}` because the parsed document is too large to "
"return directly. Read or grep that file with a narrow window."
)
async def _read_local_supported_document_result(
*,
path: str,
parsed_document: ParsedDocument,
workspace_dir: str | None,
offset: int | None,
limit: int | None,
) -> ToolExecResult:
content = parsed_document.text
if not content:
return "No content found at the requested line offset."
if not _text_exceeds_read_thresholds(content):
selected_content = _slice_text_by_lines(content, offset=offset, limit=limit)
if not selected_content:
return "No content found at the requested line offset."
if validation_error := _validate_text_output(selected_content):
return validation_error
return selected_content
if not workspace_dir:
return (
"Error reading file: parsed document exceeds the read output limit and "
"no workspace is available for storing converted text."
)
converted_text_path = await _store_converted_text_for_workspace(
workspace_dir=workspace_dir,
original_path=path,
original_bytes=parsed_document.file_bytes,
content=content,
)
if offset is None and limit is None:
return _build_converted_text_notice(
converted_text_path,
selection_returned=False,
)
selected_content = _slice_text_by_lines(content, offset=offset, limit=limit)
if not selected_content:
return (
"No content found at the requested line offset. "
+ _build_converted_text_notice(
converted_text_path,
selection_returned=False,
)
)
notice = _build_converted_text_notice(
converted_text_path,
selection_returned=True,
)
combined_output = f"{selected_content}\n\n[{notice}]"
if _validate_text_output(combined_output):
if _validate_text_output(selected_content):
return _build_converted_text_notice(
converted_text_path,
selection_returned=False,
selection_too_large=True,
)
return selected_content
return combined_output
async def read_file_tool_result(
booter: ComputerBooter,
*,
local_mode: bool,
path: str,
offset: int | None,
limit: int | None,
workspace_dir: str | None = None,
) -> ToolExecResult:
if local_mode:
probe_payload = await _probe_local_file(path)
else:
probe_payload = await _exec_python_json(
booter,
_build_probe_script(path),
action="file probe",
)
sample_b64 = str(probe_payload.get("sample_b64", "") or "")
sample = base64.b64decode(sample_b64) if sample_b64 else b""
size_bytes = int(probe_payload.get("size_bytes", 0) or 0)
probe = _probe_file(sample, size_bytes=size_bytes)
if local_mode:
try:
parsed_document = await _parse_local_supported_document(path, sample)
except Exception as exc:
return f"Error reading file: failed to parse document: {exc}"
if parsed_document is not None:
return await _read_local_supported_document_result(
path=path,
parsed_document=parsed_document,
workspace_dir=workspace_dir,
offset=offset,
limit=limit,
)
if probe.kind == "binary":
return "Error reading file: binary files are not supported by this tool."
if probe.kind == "image":
if local_mode:
image_payload = await _read_local_image_base64(path)
else:
image_payload = await _exec_python_json(
booter,
_build_image_read_script(path),
action="image read",
)
raw_base64_data = str(image_payload.get("base64", "") or "")
if not raw_base64_data:
return "Error reading file: image payload is empty."
raw_bytes = base64.b64decode(raw_base64_data)
compressed_payload = await _compress_image_bytes_to_base64(raw_bytes)
base64_data = str(compressed_payload.get("base64", "") or "")
if not base64_data:
return "Error reading file: compressed image payload is empty."
return mcp.types.CallToolResult(
content=[
mcp.types.ImageContent(
type="image",
data=base64_data,
mimeType=str(
compressed_payload.get("mime_type", "") or "image/jpeg"
),
)
]
)
if offset is None and limit is None:
if validation_error := _validate_full_text_read_request(probe):
return validation_error
if local_mode:
content = await read_local_text_range(
path,
encoding=probe.encoding or "utf-8",
offset=offset,
limit=limit,
)
else:
text_payload = await _exec_python_json(
booter,
_build_text_read_script(
path,
encoding=probe.encoding or "utf-8",
offset=offset,
limit=limit,
),
action="text read",
)
content = str(text_payload.get("content", "") or "")
if not content:
return "No content found at the requested line offset."
if validation_error := _validate_text_output(content):
return validation_error
return content

View File

@@ -12,8 +12,36 @@ class FileSystemComponent(Protocol):
"""Create a file with the specified content"""
...
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
"""Read file content"""
async def read_file(
self,
path: str,
encoding: str = "utf-8",
offset: int | None = None,
limit: int | None = None,
) -> dict[str, Any]:
"""Read file content by line window"""
...
async def search_files(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
after_context: int | None = None,
before_context: int | None = None,
) -> dict[str, Any]:
"""Search file contents"""
...
async def edit_file(
self,
path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
encoding: str = "utf-8",
) -> dict[str, Any]:
"""Edit file content by string replacement"""
...
async def write_file(

View File

@@ -1,213 +0,0 @@
import os
import uuid
from dataclasses import dataclass, field
from astrbot.api import FunctionTool, logger
from astrbot.api.event import MessageChain
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.message.components import File
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from ..computer_client import get_booter
from .permissions import check_admin_permission
# @dataclass
# class CreateFileTool(FunctionTool):
# name: str = "astrbot_create_file"
# description: str = "Create a new file in the sandbox."
# parameters: dict = field(
# default_factory=lambda: {
# "type": "object",
# "properties": {
# "path": {
# "path": "string",
# "description": "The path where the file should be created, relative to the sandbox root. Must not use absolute paths or traverse outside the sandbox.",
# },
# "content": {
# "type": "string",
# "description": "The content to write into the file.",
# },
# },
# "required": ["path", "content"],
# }
# )
# async def call(
# self, context: ContextWrapper[AstrAgentContext], path: str, content: str
# ) -> ToolExecResult:
# sb = await get_booter(
# context.context.context,
# context.context.event.unified_msg_origin,
# )
# try:
# result = await sb.fs.create_file(path, content)
# return json.dumps(result)
# except Exception as e:
# return f"Error creating file: {str(e)}"
# @dataclass
# class ReadFileTool(FunctionTool):
# name: str = "astrbot_read_file"
# description: str = "Read the content of a file in the sandbox."
# parameters: dict = field(
# default_factory=lambda: {
# "type": "object",
# "properties": {
# "path": {
# "type": "string",
# "description": "The path of the file to read, relative to the sandbox root. Must not use absolute paths or traverse outside the sandbox.",
# },
# },
# "required": ["path"],
# }
# )
# async def call(self, context: ContextWrapper[AstrAgentContext], path: str):
# sb = await get_booter(
# context.context.context,
# context.context.event.unified_msg_origin,
# )
# try:
# result = await sb.fs.read_file(path)
# return result
# except Exception as e:
# return f"Error reading file: {str(e)}"
@dataclass
class FileUploadTool(FunctionTool):
name: str = "astrbot_upload_file"
description: str = (
"Transfer a file FROM the host machine INTO the sandbox so that sandbox "
"code can access it. Use this when the user sends/attaches a file and you "
"need to process it inside the sandbox. The local_path must point to an "
"existing file on the host filesystem."
)
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"local_path": {
"type": "string",
"description": "Absolute path to the file on the host filesystem that will be copied into the sandbox.",
},
# "remote_path": {
# "type": "string",
# "description": "The filename to use in the sandbox. If not provided, file will be saved to the working directory with the same name as the local file.",
# },
},
"required": ["local_path"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
local_path: str,
) -> str | None:
if permission_error := check_admin_permission(context, "File upload/download"):
return permission_error
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
# Check if file exists
if not os.path.exists(local_path):
return f"Error: File does not exist: {local_path}"
if not os.path.isfile(local_path):
return f"Error: Path is not a file: {local_path}"
# Use basename if sandbox_filename is not provided
remote_path = os.path.basename(local_path)
# Upload file to sandbox
result = await sb.upload_file(local_path, remote_path)
logger.debug(f"Upload result: {result}")
success = result.get("success", False)
if not success:
return f"Error uploading file: {result.get('message', 'Unknown error')}"
file_path = result.get("file_path", "")
logger.info(f"File {local_path} uploaded to sandbox at {file_path}")
return f"File uploaded successfully to {file_path}"
except Exception as e:
logger.error(f"Error uploading file {local_path}: {e}")
return f"Error uploading file: {str(e)}"
@dataclass
class FileDownloadTool(FunctionTool):
name: str = "astrbot_download_file"
description: str = (
"Transfer a file FROM the sandbox OUT to the host and optionally send it "
"to the user. Use this ONLY when the user asks to retrieve/export a file "
"that was created or modified inside the sandbox."
)
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"remote_path": {
"type": "string",
"description": "Path of the file inside the sandbox to copy out to the host.",
},
"also_send_to_user": {
"type": "boolean",
"description": "Whether to also send the downloaded file to the user via message. Defaults to true.",
},
},
"required": ["remote_path"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
remote_path: str,
also_send_to_user: bool = True,
) -> ToolExecResult:
if permission_error := check_admin_permission(context, "File upload/download"):
return permission_error
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
name = os.path.basename(remote_path)
local_path = os.path.join(
get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}"
)
# Download file from sandbox
await sb.download_file(remote_path, local_path)
logger.info(f"File {remote_path} downloaded from sandbox to {local_path}")
if also_send_to_user:
try:
name = os.path.basename(local_path)
await context.context.event.send(
MessageChain(chain=[File(name=name, file=local_path)])
)
except Exception as e:
logger.error(f"Error sending file message: {e}")
# remove
# try:
# os.remove(local_path)
# except Exception as e:
# logger.error(f"Error removing temp file {local_path}: {e}")
return f"File downloaded successfully to {local_path} and sent to user."
return f"File downloaded successfully to {local_path}"
except Exception as e:
logger.error(f"Error downloading file {remote_path}: {e}")
return f"Error downloading file: {str(e)}"

View File

@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.22.3"
VERSION = "4.23.0"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
@@ -454,6 +454,7 @@ CONFIG_METADATA_2 = {
"discord_proxy": "",
"discord_command_register": True,
"discord_activity_name": "",
"discord_allow_bot_messages": False,
},
"Misskey": {
"id": "misskey",
@@ -919,6 +920,11 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "可选的 Discord 活动名称。留空则不设置活动。",
},
"discord_allow_bot_messages": {
"description": "允许接收机器人消息",
"type": "bool",
"hint": "启用后AstrBot 将接收来自其他 Discord 机器人的消息。适用于机器人间通信场景(如消息转发)。默认关闭。",
},
"port": {
"description": "回调服务器端口",
"type": "int",

View File

@@ -15,9 +15,12 @@ else:
class DiscordBotClient(discord.Bot):
"""Discord客户端封装"""
def __init__(self, token: str, proxy: str | None = None) -> None:
def __init__(
self, token: str, proxy: str | None = None, allow_bot_messages: bool = False
) -> None:
self.token = token
self.proxy = proxy
self.allow_bot_messages = allow_bot_messages
# 设置Intent权限遵循权限最小化原则
intents = discord.Intents.default()
@@ -95,7 +98,7 @@ class DiscordBotClient(discord.Bot):
async def on_message(self, message: discord.Message) -> None:
"""当接收到消息时触发"""
if message.author.bot:
if message.author.bot and not self.allow_bot_messages:
return
logger.debug(

View File

@@ -142,7 +142,8 @@ class DiscordPlatformAdapter(Platform):
return
proxy = self.config.get("discord_proxy") or None
self.client = DiscordBotClient(token, proxy)
allow_bot_messages = bool(self.config.get("discord_allow_bot_messages"))
self.client = DiscordBotClient(token, proxy, allow_bot_messages)
self.client.on_message_received = on_received
async def callback() -> None:
@@ -435,6 +436,21 @@ class DiscordPlatformAdapter(Platform):
async def dynamic_callback(
ctx: discord.ApplicationContext, params: str | None = None
) -> None:
# 1. 嘗試立即响应,防止超时 (移到最前面)
followup_webhook = None
try:
# 設定 2.5 秒超時,避免卡死整個 event loop
await asyncio.wait_for(ctx.defer(), timeout=2.5)
followup_webhook = ctx.followup
except asyncio.TimeoutError:
logger.warning(
f"[Discord] Defer command '{cmd_name}' timeout. Network might be too slow."
)
return
except Exception as e:
logger.warning(f"[Discord] Failed to defer command '{cmd_name}': {e}")
return
# 将平台特定的前缀'/'剥离以适配通用的CommandFilter
logger.debug(f"[Discord] Callback triggered: {cmd_name}")
logger.debug(f"[Discord] Callback context: {ctx}")
@@ -449,14 +465,6 @@ class DiscordPlatformAdapter(Platform):
f"Built command string: '{message_str_for_filter}'",
)
# 尝试立即响应,防止超时
followup_webhook = None
try:
await ctx.defer()
followup_webhook = ctx.followup
except Exception as e:
logger.warning(f"[Discord] Failed to defer command '{cmd_name}': {e}")
# 2. 构建 AstrBotMessage
channel = ctx.channel
abm = AstrBotMessage()

View File

@@ -1,5 +1,6 @@
import asyncio
import base64
import logging
import os
import random
import uuid
@@ -15,6 +16,13 @@ from botpy import Client
from botpy.http import Route
from botpy.types import message
from botpy.types.message import MarkdownPayload, Media
from tenacity import (
before_sleep_log,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -44,6 +52,20 @@ def _patch_qq_botpy_formdata() -> None:
_patch_qq_botpy_formdata()
# Retry decorator for QQ Official API transient errors (HTTP 500/504)
_qqofficial_retry = retry(
retry=retry_if_exception_type(
(
botpy.errors.ServerError,
botpy.errors.SequenceNumberError,
)
),
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
class QQOfficialMessageEvent(AstrMessageEvent):
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
@@ -453,21 +475,26 @@ class QQOfficialMessageEvent(AstrMessageEvent):
"srv_send_msg": False,
}
result = None
if "openid" in kwargs:
payload["openid"] = kwargs["openid"]
route = Route("POST", "/v2/users/{openid}/files", openid=kwargs["openid"])
result = await self.bot.api._http.request(route, json=payload)
elif "group_openid" in kwargs:
payload["group_openid"] = kwargs["group_openid"]
route = Route(
"POST",
"/v2/groups/{group_openid}/files",
group_openid=kwargs["group_openid"],
)
result = await self.bot.api._http.request(route, json=payload)
else:
raise ValueError("Invalid upload parameters")
@_qqofficial_retry
async def _do_upload():
if "openid" in kwargs:
payload["openid"] = kwargs["openid"]
route = Route(
"POST", "/v2/users/{openid}/files", openid=kwargs["openid"]
)
return await self.bot.api._http.request(route, json=payload)
elif "group_openid" in kwargs:
payload["group_openid"] = kwargs["group_openid"]
route = Route(
"POST",
"/v2/groups/{group_openid}/files",
group_openid=kwargs["group_openid"],
)
return await self.bot.api._http.request(route, json=payload)
else:
raise ValueError("Invalid upload parameters")
result = await _do_upload()
if not isinstance(result, dict):
raise RuntimeError(
@@ -490,7 +517,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
) -> Media | None:
"""上传媒体文件"""
# 构建基础payload
payload = {"file_type": file_type, "srv_send_msg": srv_send_msg}
payload: dict = {"file_type": file_type, "srv_send_msg": srv_send_msg}
if file_name:
payload["file_name"] = file_name
@@ -519,9 +546,12 @@ class QQOfficialMessageEvent(AstrMessageEvent):
else:
return None
@_qqofficial_retry
async def _do_upload():
return await self.bot.api._http.request(route, json=payload)
try:
# 使用底层HTTP请求
result = await self.bot.api._http.request(route, json=payload)
result = await _do_upload()
if result:
if not isinstance(result, dict):
@@ -533,6 +563,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
file_info=result["file_info"],
ttl=result.get("ttl", 0),
)
except (botpy.errors.ServerError, botpy.errors.SequenceNumberError):
logger.error(f"上传媒体文件失败共尝试3次后放弃: {file_source}")
except Exception as e:
logger.error(f"上传请求错误: {e}")

View File

@@ -3,12 +3,13 @@ import os
import re
import sys
import uuid
from contextlib import suppress
from typing import cast
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from telegram import BotCommand, Update
from telegram.constants import ChatType
from telegram.error import Forbidden, InvalidToken
from telegram.error import Forbidden, InvalidToken, NetworkError
from telegram.ext import ApplicationBuilder, ContextTypes, ExtBot, filters
from telegram.ext import MessageHandler as TelegramMessageHandler
@@ -66,6 +67,7 @@ class TelegramPlatformAdapter(Platform):
file_base_url = "https://api.telegram.org/file/bot"
self.base_url = base_url
self.file_base_url = file_base_url
self.enable_command_register = self.config.get(
"telegram_command_register",
@@ -77,23 +79,12 @@ class TelegramPlatformAdapter(Platform):
)
self.last_command_hash = None
self.application = (
ApplicationBuilder()
.token(self.config["telegram_token"])
.base_url(base_url)
.base_file_url(file_base_url)
.build()
)
message_handler = TelegramMessageHandler(
filters=filters.ALL, # receive all messages
callback=self.message_handler,
)
self.application.add_handler(message_handler)
self.client = self.application.bot
logger.debug(f"Telegram base url: {self.client.base_url}")
self.scheduler = AsyncIOScheduler()
self._terminating = False
self._loop: asyncio.AbstractEventLoop | None = None
self._polling_recovery_requested = asyncio.Event()
self._consecutive_polling_failures = 0
self._last_polling_failure_at = 0.0
raw_delay = self.config.get("telegram_polling_restart_delay", 5.0)
try:
delay = float(raw_delay)
@@ -113,6 +104,10 @@ class TelegramPlatformAdapter(Platform):
)
delay = 0.1
self._polling_restart_delay = delay
self._polling_recovery_threshold = 3
self._polling_failure_window = 60.0
self._application_started = False
self._build_application()
# Media group handling
# Cache structure: {media_group_id: {"created_at": datetime, "items": [(update, context), ...]}}
@@ -124,6 +119,85 @@ class TelegramPlatformAdapter(Platform):
"telegram_media_group_max_wait", 10.0
) # max seconds - hard cap to prevent indefinite delay
def _build_application(self) -> None:
self.application = (
ApplicationBuilder()
.token(self.config["telegram_token"])
.base_url(self.base_url)
.base_file_url(self.file_base_url)
.build()
)
message_handler = TelegramMessageHandler(
filters=filters.ALL,
callback=self.message_handler,
)
self.application.add_handler(message_handler)
self.client = self.application.bot
logger.debug(f"Telegram base url: {self.client.base_url}")
async def _start_application(self) -> None:
await self.application.initialize()
await self.application.start()
if self.enable_command_register:
await self.register_commands()
self._application_started = True
async def _shutdown_application(
self,
*,
delete_commands: bool,
) -> None:
self._application_started = False
updater = self.application.updater
if updater is not None:
with suppress(Exception):
await updater.stop()
if delete_commands and self.enable_command_register:
with suppress(Exception):
await self.client.delete_my_commands()
with suppress(Exception):
await self.application.stop()
shutdown = getattr(self.application, "shutdown", None)
if shutdown is not None:
with suppress(Exception):
await shutdown()
async def _recreate_application(self) -> None:
if self._terminating:
self._polling_recovery_requested.clear()
return
logger.warning(
"Telegram polling hit repeated network errors; rebuilding the "
"Telegram application and HTTP client.",
)
await self._shutdown_application(delete_commands=False)
self._build_application()
self._consecutive_polling_failures = 0
self._last_polling_failure_at = 0.0
self._polling_recovery_requested.clear()
def _start_command_scheduler(self) -> None:
if not self.enable_command_refresh or not self.enable_command_register:
return
if self.scheduler.running:
return
self.scheduler.add_job(
self.register_commands,
"interval",
seconds=self.config.get("telegram_command_register_interval", 300),
id="telegram_command_register",
misfire_grace_time=60,
)
self.scheduler.start()
@override
async def send_by_session(
self,
@@ -145,41 +219,42 @@ class TelegramPlatformAdapter(Platform):
@override
async def run(self) -> None:
await self.application.initialize()
await self.application.start()
if self.enable_command_register:
await self.register_commands()
if self.enable_command_refresh and self.enable_command_register:
self.scheduler.add_job(
self.register_commands,
"interval",
seconds=self.config.get("telegram_command_register_interval", 300),
id="telegram_command_register",
misfire_grace_time=60,
)
self.scheduler.start()
if not self.application.updater:
logger.error("Telegram Updater is not initialized. Cannot start polling.")
return
self._loop = asyncio.get_running_loop()
self._start_command_scheduler()
while not self._terminating:
try:
if not self._application_started:
await self._start_application()
self._polling_recovery_requested.clear()
updater = self.application.updater
if updater is None:
logger.error(
"Telegram Updater is not initialized. Cannot start polling."
)
self._application_started = False
await asyncio.sleep(self._polling_restart_delay)
continue
logger.info("Starting Telegram polling...")
await self.application.updater.start_polling(
error_callback=self._on_polling_error
)
await updater.start_polling(error_callback=self._on_polling_error)
logger.info("Telegram Platform Adapter is running.")
while self.application.updater.running and not self._terminating: # noqa: ASYNC110
while updater.running and not self._terminating: # noqa: ASYNC110
if self._polling_recovery_requested.is_set():
await self._recreate_application()
break
await asyncio.sleep(1)
else:
if not self._terminating:
logger.warning(
"Telegram polling loop exited unexpectedly, "
f"retrying in {self._polling_restart_delay}s."
)
continue
if not self._terminating:
logger.warning(
"Telegram polling loop exited unexpectedly, "
f"retrying in {self._polling_restart_delay}s."
)
logger.info("Telegram polling restarted with a fresh client.")
continue
except asyncio.CancelledError:
raise
except (Forbidden, InvalidToken) as e:
@@ -193,6 +268,9 @@ class TelegramPlatformAdapter(Platform):
f"{type(e).__name__}: {e!s}. "
f"Retrying in {self._polling_restart_delay}s.",
)
with suppress(Exception):
await self._shutdown_application(delete_commands=False)
self._build_application()
if not self._terminating:
await asyncio.sleep(self._polling_restart_delay)
@@ -202,6 +280,33 @@ class TelegramPlatformAdapter(Platform):
f"Telegram polling request failed: {type(error).__name__}: {error!s}",
exc_info=error,
)
if not isinstance(error, NetworkError):
return
if self._loop is None:
return
now = self._loop.time()
if now - self._last_polling_failure_at > self._polling_failure_window:
self._consecutive_polling_failures = 0
self._last_polling_failure_at = now
self._consecutive_polling_failures += 1
if self._consecutive_polling_failures < self._polling_recovery_threshold:
return
logger.warning(
"Telegram polling encountered %s network failures within %.1fs; "
"scheduling client rebuild.",
self._consecutive_polling_failures,
self._polling_failure_window,
)
if self._loop.is_closed():
return
try:
self._loop.call_soon_threadsafe(self._polling_recovery_requested.set)
except RuntimeError:
return
async def register_commands(self) -> None:
"""收集所有注册的指令并注册到 Telegram"""
@@ -634,15 +739,8 @@ class TelegramPlatformAdapter(Platform):
self._terminating = True
if self.scheduler.running:
self.scheduler.shutdown()
await self.application.stop()
if self.enable_command_register:
await self.client.delete_my_commands()
# 保险起见先判断是否存在updater对象
if self.application.updater is not None:
await self.application.updater.stop()
self._polling_recovery_requested.set()
await self._shutdown_application(delete_commands=True)
logger.info("Telegram adapter has been closed.")
except Exception as e:

View File

@@ -105,6 +105,30 @@ class TelegramPlatformEvent(AstrMessageEvent):
return chunks
@classmethod
async def _send_text_chunks(
cls,
client: ExtBot,
text: str,
payload: dict[str, Any],
) -> None:
"""按 Telegram 限制切分文本后逐段发送。"""
for chunk in cls._split_message(text):
try:
markdown_text = telegramify_markdown.markdownify(
chunk,
)
await client.send_message(
text=markdown_text,
parse_mode="MarkdownV2",
**cast(Any, payload),
)
except (ValueError, BadRequest) as e:
logger.warning(
f"Failed to convert message to Markdownusing normal text: {e!s}"
)
await client.send_message(text=chunk, **cast(Any, payload))
@classmethod
async def _send_chat_action(
cls,
@@ -283,22 +307,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
if at_user_id and not at_flag:
i.text = f"@{at_user_id} {i.text}"
at_flag = True
chunks = cls._split_message(i.text)
for chunk in chunks:
try:
md_text = telegramify_markdown.markdownify(
chunk,
)
await client.send_message(
text=md_text,
parse_mode="MarkdownV2",
**cast(Any, payload),
)
except Exception as e:
logger.warning(
f"MarkdownV2 send failed: {e}. Using plain text instead.",
)
await client.send_message(text=chunk, **cast(Any, payload))
await cls._send_text_chunks(client, i.text, payload)
elif isinstance(i, Image):
image_path = await i.convert_to_file_path()
if _is_gif(image_path):
@@ -479,18 +488,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
async def _send_final_segment(self, delta: str, payload: dict[str, Any]) -> None:
"""将累积文本作为 MarkdownV2 真实消息发送,失败时回退到纯文本。"""
try:
markdown_text = telegramify_markdown.markdownify(
delta,
)
await self.client.send_message(
text=markdown_text,
parse_mode="MarkdownV2",
**cast(Any, payload),
)
except Exception as e:
logger.warning(f"Markdown转换失败使用普通文本: {e!s}")
await self.client.send_message(text=delta, **cast(Any, payload))
await self._send_text_chunks(self.client, delta, payload)
async def send_streaming(self, generator, use_fallback: bool = False):
message_thread_id = None

View File

@@ -6,6 +6,7 @@ import hashlib
import io
import time
import uuid
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
@@ -15,7 +16,7 @@ import qrcode as qrcode_lib
from astrbot import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import File, Image, Plain, Record, Video
from astrbot.api.message_components import File, Image, Plain, Record, Reply, Video
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
@@ -60,6 +61,34 @@ class TypingSessionState:
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
@dataclass
class WeixinOCRecentMessage:
message_id: str
sender_id: str
sender_nickname: str
timestamp: int
timestamp_ms: int
components: list[Any]
message_str: str
message_kind: str
@dataclass
class WeixinOCRecentSessionCache:
messages: deque[WeixinOCRecentMessage]
updated_at: float
@dataclass
class WeixinOCReplyMeta:
is_reply: bool = False
ref_msg: dict[str, Any] | None = None
reply_kind: str | None = None
quoted_item_type: int | None = None
quoted_text: str | None = None
reply_to: dict[str, Any] = field(default_factory=lambda: {"matched": False})
@register_platform_adapter(
"weixin_oc",
"个人微信",
@@ -73,6 +102,10 @@ class WeixinOCAdapter(Platform):
IMAGE_UPLOAD_TYPE = 1
VIDEO_UPLOAD_TYPE = 2
FILE_UPLOAD_TYPE = 3
RECENT_MESSAGE_CACHE_SIZE = 100
REPLY_MATCH_WINDOW_MS = 60_000
RECENT_SESSION_CACHE_TTL_S = 1_800
MAX_RECENT_MESSAGE_SESSIONS = 500
def __init__(
self,
@@ -118,6 +151,22 @@ class WeixinOCAdapter(Platform):
self._context_tokens: dict[str, str] = {}
self._typing_states: dict[str, TypingSessionState] = {}
self._last_inbound_error = ""
self._recent_message_cache_size = self._get_int_config(
"weixin_oc_recent_message_cache_size",
self.RECENT_MESSAGE_CACHE_SIZE,
1,
)
self._recent_session_cache_ttl_s = self._get_int_config(
"weixin_oc_recent_session_cache_ttl_s",
self.RECENT_SESSION_CACHE_TTL_S,
60,
)
self._max_recent_message_sessions = self._get_int_config(
"weixin_oc_max_recent_message_sessions",
self.MAX_RECENT_MESSAGE_SESSIONS,
1,
)
self._recent_messages: dict[str, WeixinOCRecentSessionCache] = {}
self._typing_keepalive_interval_s = max(
1,
int(platform_config.get("weixin_oc_typing_keepalive_interval", 5)),
@@ -152,6 +201,18 @@ class WeixinOCAdapter(Platform):
self.client.api_timeout_ms = self.api_timeout_ms
self.client.token = self.token
def _get_int_config(
self,
key: str,
default: int,
minimum: int,
) -> int:
try:
value = int(self.config.get(key, default))
except (TypeError, ValueError):
value = default
return max(minimum, value)
def _get_typing_state(self, user_id: str) -> TypingSessionState:
state = self._typing_states.get(user_id)
if state is None:
@@ -201,12 +262,12 @@ class WeixinOCAdapter(Platform):
return state.ticket
payload = await self.client.get_typing_config(user_id, context_token)
if int(payload.get("ret") or 0) != 0:
if not self._is_successful_api_payload(payload):
logger.warning(
"weixin_oc(%s): getconfig failed for %s: %s",
self.meta().id,
user_id,
payload.get("errmsg", ""),
self._format_api_error(payload),
)
return None
@@ -227,9 +288,9 @@ class WeixinOCAdapter(Platform):
cancel: bool,
) -> None:
payload = await self.client.send_typing_state(user_id, ticket, cancel=cancel)
if int(payload.get("ret") or 0) != 0:
if not self._is_successful_api_payload(payload):
raise RuntimeError(
f"sendtyping failed for {user_id}: {payload.get('errmsg', '')}"
f"sendtyping failed for {user_id}: {self._format_api_error(payload)}"
)
async def _run_typing_keepalive(self, user_id: str) -> None:
@@ -769,6 +830,9 @@ class WeixinOCAdapter(Platform):
self,
user_id: str,
item_list: list[dict[str, Any]],
*,
cache_components: list[Any] | None = None,
cache_message_str: str | None = None,
) -> bool:
if not self.token:
logger.warning("weixin_oc(%s): missing token, skip send", self.meta().id)
@@ -787,7 +851,7 @@ class WeixinOCAdapter(Platform):
user_id,
)
return False
await self.client.request_json(
payload = await self.client.request_json(
"POST",
"ilink/bot/sendmessage",
payload={
@@ -807,8 +871,65 @@ class WeixinOCAdapter(Platform):
token_required=True,
headers={},
)
if not self._is_successful_api_payload(payload):
logger.warning(
"weixin_oc(%s): sendmessage failed for %s: %s",
self.meta().id,
user_id,
self._format_api_error(payload),
)
return False
resolved_cache_components = (
list(cache_components)
if cache_components is not None
else self._build_cache_components_from_items(item_list)
)
sender_id = str(self.account_id or self.meta().id)
sent_at_ms = time.time_ns() // 1_000_000
self._cache_recent_message(
user_id,
message_id=uuid.uuid4().hex,
sender_id=sender_id,
sender_nickname=sender_id,
timestamp=sent_at_ms // 1000,
timestamp_ms=sent_at_ms,
components=resolved_cache_components,
message_str=cache_message_str
if cache_message_str is not None
else self._message_text_from_item_list(
item_list,
include_ref_text=False,
),
)
return True
def _build_cache_components_from_items(
self,
item_list: list[dict[str, Any]],
) -> list[Any]:
components: list[Any] = []
for item in item_list:
item_type = int(item.get("type") or 0)
if item_type != 1:
continue
text = str(item.get("text_item", {}).get("text", "")).strip()
if text:
components.append(Plain(text))
return components
@staticmethod
def _is_successful_api_payload(payload: dict[str, Any]) -> bool:
ret = payload.get("ret", 0)
errcode = payload.get("errcode", 0)
return int(ret or 0) == 0 and int(errcode or 0) == 0
@staticmethod
def _format_api_error(payload: dict[str, Any]) -> str:
ret = int(payload.get("ret") or 0)
errcode = int(payload.get("errcode") or 0)
errmsg = str(payload.get("errmsg", ""))
return f"ret={ret}, errcode={errcode}, errmsg={errmsg}"
async def _send_media_segment(
self,
user_id: str,
@@ -858,8 +979,18 @@ class WeixinOCAdapter(Platform):
await self._send_items_to_session(
user_id,
[self._build_plain_text_item(text)],
cache_components=[Plain(text)],
cache_message_str=text,
)
return await self._send_items_to_session(user_id, [media_item])
return await self._send_items_to_session(
user_id,
[media_item],
cache_components=[segment],
cache_message_str=self._message_text_from_item_list(
[media_item],
include_ref_text=False,
),
)
async def _start_login_session(self) -> OpenClawLoginSession:
endpoint = "ilink/bot/get_bot_qrcode"
@@ -961,7 +1092,10 @@ class WeixinOCAdapter(Platform):
await self._save_account_state()
def _message_text_from_item_list(
self, item_list: list[dict[str, Any]] | None
self,
item_list: list[dict[str, Any]] | None,
*,
include_ref_text: bool = False,
) -> str:
if not item_list:
return ""
@@ -985,15 +1119,298 @@ class WeixinOCAdapter(Platform):
elif item_type == 5:
text_parts.append("[视频]")
else:
ref = item.get("ref_msg")
if isinstance(ref, dict):
ref_item = ref.get("message_item")
if isinstance(ref_item, dict):
ref_text = str(self._message_text_from_item_list([ref_item]))
if ref_text:
text_parts.append(f"[引用:{ref_text}]")
if include_ref_text:
ref = item.get("ref_msg")
if isinstance(ref, dict):
ref_item = ref.get("message_item")
if isinstance(ref_item, dict):
ref_text = str(
self._message_text_from_item_list(
[ref_item],
include_ref_text=True,
)
)
if ref_text:
text_parts.append(f"[引用:{ref_text}]")
return "\n".join(text_parts).strip()
def _item_type_to_kind(self, item_type: int | None) -> str:
match int(item_type or 0):
case 1:
return "text"
case self.IMAGE_ITEM_TYPE:
return "image"
case self.VOICE_ITEM_TYPE:
return "voice"
case self.FILE_ITEM_TYPE:
return "file"
case self.VIDEO_ITEM_TYPE:
return "video"
case _:
return "unknown"
def _get_recent_message_cache(
self,
session_id: str,
) -> deque[WeixinOCRecentMessage]:
now = time.monotonic()
self._prune_recent_message_caches(now=now)
cache_entry = self._recent_messages.get(session_id)
if cache_entry is None:
cache_entry = WeixinOCRecentSessionCache(
messages=deque(maxlen=self._recent_message_cache_size),
updated_at=now,
)
self._recent_messages[session_id] = cache_entry
else:
cache_entry.updated_at = now
return cache_entry.messages
def _prune_recent_message_caches(self, *, now: float | None = None) -> None:
if not self._recent_messages:
return
current = now if now is not None else time.monotonic()
expired_session_ids = [
session_id
for session_id, cache_entry in self._recent_messages.items()
if current - cache_entry.updated_at > self._recent_session_cache_ttl_s
]
for session_id in expired_session_ids:
self._recent_messages.pop(session_id, None)
overflow = len(self._recent_messages) - self._max_recent_message_sessions
if overflow <= 0:
return
oldest_session_ids = sorted(
self._recent_messages,
key=lambda session_id: self._recent_messages[session_id].updated_at,
)[:overflow]
for session_id in oldest_session_ids:
self._recent_messages.pop(session_id, None)
def _infer_message_kind_from_components(self, components: list[Any]) -> str:
if not components:
return "unknown"
for component in components:
if isinstance(component, Plain) and component.text.strip():
return "text"
if isinstance(component, Image):
return "image"
if isinstance(component, Record):
return "voice"
if isinstance(component, File):
return "file"
if isinstance(component, Video):
return "video"
return "unknown"
def _cache_recent_message(
self,
session_id: str,
*,
message_id: str,
sender_id: str,
sender_nickname: str,
timestamp: int,
timestamp_ms: int | None = None,
components: list[Any],
message_str: str,
message_kind: str | None = None,
) -> None:
if not session_id or not message_id:
return
resolved_timestamp_ms = (
timestamp_ms if timestamp_ms is not None else timestamp * 1000
)
cache = self._get_recent_message_cache(session_id)
cache.append(
WeixinOCRecentMessage(
message_id=message_id,
sender_id=sender_id,
sender_nickname=sender_nickname,
timestamp=timestamp,
timestamp_ms=resolved_timestamp_ms,
components=list(components),
message_str=message_str,
message_kind=message_kind
or self._infer_message_kind_from_components(components),
)
)
def _match_recent_reply(
self,
session_id: str,
*,
ref_create_time_ms: int | None,
) -> tuple[WeixinOCRecentMessage | None, dict[str, Any] | None]:
if not session_id or ref_create_time_ms is None:
return None, None
best_match: WeixinOCRecentMessage | None = None
best_distance: int | None = None
self._prune_recent_message_caches()
cache_entry = self._recent_messages.get(session_id)
if cache_entry is None:
return None, None
for candidate in cache_entry.messages:
distance = abs(candidate.timestamp_ms - ref_create_time_ms)
if distance > self.REPLY_MATCH_WINDOW_MS:
continue
if best_distance is None or distance < best_distance:
best_match = candidate
best_distance = distance
if best_match is None or best_distance is None:
return None, None
confidence = max(
0.0,
min(1.0, 1.0 - (best_distance / self.REPLY_MATCH_WINDOW_MS)),
)
return best_match, {
"matched": True,
"strategy": "nearest-message-by-timestamp",
"ref_create_time_ms": ref_create_time_ms,
"matched_message_id": best_match.message_id,
"matched_kind": best_match.message_kind,
"distance_ms": best_distance,
"confidence": round(confidence, 4),
}
async def _build_reply_component_from_ref(
self,
*,
session_id: str,
ref_msg: dict[str, Any],
) -> tuple[Reply | None, WeixinOCReplyMeta]:
metadata = WeixinOCReplyMeta(ref_msg=ref_msg)
message_item = ref_msg.get("message_item")
if not isinstance(message_item, dict):
return None, metadata
quoted_item_type_raw = message_item.get("type")
try:
quoted_item_type = (
int(quoted_item_type_raw)
if quoted_item_type_raw not in (None, "")
else None
)
except (TypeError, ValueError):
quoted_item_type = None
metadata.quoted_item_type = quoted_item_type
metadata.reply_kind = self._item_type_to_kind(quoted_item_type)
ref_create_time_ms_raw = message_item.get("create_time_ms")
try:
ref_create_time_ms = (
int(ref_create_time_ms_raw)
if ref_create_time_ms_raw not in (None, "")
else None
)
except (TypeError, ValueError):
ref_create_time_ms = None
quoted_components: list[Any] = []
quoted_text = ""
if quoted_item_type is not None:
quoted_components = await self._item_list_to_components([message_item])
quoted_text = self._message_text_from_item_list(
[message_item],
include_ref_text=False,
)
if quoted_text:
metadata.quoted_text = quoted_text
metadata.reply_to = {
"matched": True,
"strategy": "direct-ref-msg",
"matched_kind": metadata.reply_kind,
"matched_text": quoted_text,
"confidence": 1.0,
}
matched_message = None
matched_reply_to = None
if not quoted_text or not quoted_components:
matched_message, matched_reply_to = self._match_recent_reply(
session_id,
ref_create_time_ms=ref_create_time_ms,
)
if matched_message is not None:
quoted_components = list(matched_message.components)
quoted_text = matched_message.message_str
metadata.quoted_text = quoted_text or None
metadata.reply_kind = matched_message.message_kind
metadata.reply_to = matched_reply_to or {"matched": True}
if not quoted_text and not quoted_components:
return None, metadata
metadata.is_reply = True
reply_message_id = (
matched_message.message_id
if matched_message is not None
else str(
message_item.get("message_id")
or message_item.get("msg_id")
or f"weixin_oc_ref_{ref_create_time_ms or uuid.uuid4().hex}"
)
)
quoted_sender_id_raw = str(message_item.get("from_user_id") or "unknown")
reply_sender_id_raw = (
matched_message.sender_id
if matched_message is not None
else quoted_sender_id_raw
)
normalized_reply_sender_id = self._normalize_reply_sender_id(
reply_sender_id_raw
)
reply_sender_id = (
normalized_reply_sender_id
if normalized_reply_sender_id
else reply_sender_id_raw
)
reply_sender_nickname = (
matched_message.sender_nickname
if matched_message is not None
else quoted_sender_id_raw
)
reply_time = (
matched_message.timestamp
if matched_message is not None
else (
int(ref_create_time_ms / 1000)
if isinstance(ref_create_time_ms, int)
else int(time.time())
)
)
return (
Reply(
id=reply_message_id,
chain=quoted_components,
sender_id=reply_sender_id,
sender_nickname=reply_sender_nickname,
time=reply_time,
message_str=quoted_text,
text=quoted_text,
),
metadata,
)
def _normalize_reply_sender_id(self, sender_id: str) -> str:
normalized_sender_id = sender_id.strip()
if not normalized_sender_id:
return normalized_sender_id
if self.account_id and normalized_sender_id == str(self.account_id):
return self.meta().id
return normalized_sender_id
async def _item_list_to_components(
self, item_list: list[dict[str, Any]] | None
) -> list[Any]:
@@ -1031,16 +1448,36 @@ class WeixinOCAdapter(Platform):
self._context_tokens[from_user_id] = context_token
item_list = cast(list[dict[str, Any]], msg.get("item_list", []))
components = await self._item_list_to_components(item_list)
text = self._message_text_from_item_list(item_list)
reply_component = None
reply_metadata = WeixinOCReplyMeta()
for item in item_list:
ref_msg = item.get("ref_msg")
if isinstance(ref_msg, dict):
(
reply_component,
reply_metadata,
) = await self._build_reply_component_from_ref(
session_id=from_user_id,
ref_msg=ref_msg,
)
break
cached_components = await self._item_list_to_components(item_list)
components = list(cached_components)
if reply_component is not None:
components.insert(0, reply_component)
text = self._message_text_from_item_list(item_list, include_ref_text=False)
message_id = str(msg.get("message_id") or msg.get("msg_id") or uuid.uuid4().hex)
create_time = msg.get("create_time_ms") or msg.get("create_time")
create_time_ms: int | None = None
if isinstance(create_time, (int, float)) and create_time > 1_000_000_000_000:
create_time_ms = int(create_time)
ts = int(float(create_time) / 1000)
elif isinstance(create_time, (int, float)):
ts = int(create_time)
create_time_ms = ts * 1000
else:
ts = int(time.time())
create_time_ms = ts * 1000
abm = AstrBotMessage()
abm.self_id = self.meta().id
@@ -1052,6 +1489,23 @@ class WeixinOCAdapter(Platform):
abm.message_str = text
abm.timestamp = ts
abm.raw_message = msg
abm.is_reply = reply_metadata.is_reply
abm.ref_msg = reply_metadata.ref_msg
abm.reply_kind = reply_metadata.reply_kind
abm.quoted_item_type = reply_metadata.quoted_item_type
abm.quoted_text = reply_metadata.quoted_text
abm.reply_to = reply_metadata.reply_to
self._cache_recent_message(
from_user_id,
message_id=message_id,
sender_id=from_user_id,
sender_nickname=from_user_id,
timestamp=ts,
timestamp_ms=create_time_ms,
components=cached_components,
message_str=text,
)
self.commit_event(
WeixinOCMessageEvent(
@@ -1076,20 +1530,8 @@ class WeixinOCAdapter(Platform):
token_required=True,
timeout_ms=self.long_poll_timeout_ms,
)
ret = int(data.get("ret") or 0)
errcode = data.get("errcode", 0)
if ret != 0 and ret is not None:
errmsg = str(data.get("errmsg", ""))
self._last_inbound_error = f"ret={ret}, errcode={errcode}, errmsg={errmsg}"
logger.warning(
"weixin_oc(%s): getupdates error: %s",
self.meta().id,
self._last_inbound_error,
)
return
if errcode and int(errcode) != 0:
errmsg = str(data.get("errmsg", ""))
self._last_inbound_error = f"ret={ret}, errcode={errcode}, errmsg={errmsg}"
if not self._is_successful_api_payload(data):
self._last_inbound_error = self._format_api_error(data)
logger.warning(
"weixin_oc(%s): getupdates error: %s",
self.meta().id,

View File

@@ -1,4 +1,5 @@
import os
from typing import Any
import aiohttp
@@ -88,8 +89,6 @@ class BailianRerankProvider(RerankProvider):
normalized_model = self.model.strip().lower()
normalized_top_n = top_n if top_n is not None and top_n > 0 else None
# qwen3-rerank follows a model-specific payload:
# query/documents/top_n/instruct should be at the top level.
if normalized_model == self.QWEN3_RERANK_MODEL:
payload = {
"model": self.model,
@@ -107,8 +106,7 @@ class BailianRerankProvider(RerankProvider):
)
return payload
base = {"model": self.model, "input": {"query": query, "documents": documents}}
payload_input = {"query": query, "documents": documents}
params = {
k: v
for k, v in [
@@ -118,6 +116,7 @@ class BailianRerankProvider(RerankProvider):
if v is not None
}
base: dict[str, Any] = {"model": self.model, "input": payload_input}
if params:
base["parameters"] = params
@@ -136,14 +135,23 @@ class BailianRerankProvider(RerankProvider):
BailianAPIError: API返回错误
KeyError: 结果缺少必要字段
"""
# 检查响应状态
if data.get("code", "200") != "200":
raise BailianAPIError(
f"百炼 API 错误: {data.get('code')} {data.get('message', '')}"
)
is_compatible_api = "compatible-api" in self.base_url
if is_compatible_api:
code = data.get("code")
if code:
raise BailianAPIError(
f"百炼 API 错误: {code} {data.get('message', '')}"
)
results = data.get("results", [])
else:
code = data.get("code", "200")
if code != "200":
raise BailianAPIError(
f"百炼 API 错误: {code} {data.get('message', '')}"
)
results = data.get("output", {}).get("results", [])
# 兼容旧版 API (output.results) 和新版 compatible API (results)
results = (data.get("output") or {}).get("results") or data.get("results") or []
if not results:
logger.warning(f"百炼 Rerank 返回空结果: {data}")
return []

View File

@@ -36,6 +36,7 @@ from astrbot.core.star.filter.platform_adapter_type import (
PlatformAdapterType,
)
from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path
from ..exceptions import ProviderNotFoundError
from .filter.command import CommandFilter
@@ -232,6 +233,13 @@ class Context:
for k, v in kwargs.items()
if k not in ["stream", "agent_hooks", "agent_context"]
}
if request.func_tool and request.func_tool.get_tool("astrbot_file_read_tool"):
other_kwargs.setdefault(
"tool_result_overflow_dir", get_astrbot_system_tmp_path()
)
other_kwargs.setdefault(
"read_tool", request.func_tool.get_tool("astrbot_file_read_tool")
)
await agent_runner.reset(
provider=prov,
@@ -486,7 +494,7 @@ class Context:
_parts.append(part)
if part in flags and i + 1 < len(module_part):
_parts.append(module_part[i + 1])
module_part.append("main")
_parts.append("main")
break
tool.handler_module_path = ".".join(_parts)
module_path = tool.handler_module_path

View File

@@ -11,6 +11,8 @@ import os
import sys
import tempfile
import traceback
from dataclasses import dataclass
from enum import Enum, auto
from types import ModuleType
import yaml
@@ -37,6 +39,7 @@ from astrbot.core.utils.astrbot_path import (
from astrbot.core.utils.io import remove_dir
from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.requirements_utils import (
MissingRequirementsPlan,
plan_missing_requirements_install,
)
@@ -77,6 +80,19 @@ class PluginDependencyInstallError(Exception):
self.error = error
class ImportDependencyRecoveryMode(Enum):
DISABLED = auto()
PRELOAD_AND_RECOVER = auto()
RECOVER_ON_FAILURE = auto()
REINSTALL_ON_FAILURE = auto()
@dataclass(frozen=True)
class ImportDependencyRecoveryState:
mode: ImportDependencyRecoveryMode
install_plan: MissingRequirementsPlan | None = None
@contextlib.contextmanager
def _temporary_filtered_requirements_file(
*,
@@ -137,7 +153,10 @@ async def _install_requirements_with_precheck(
requirements_path,
fallback_reason,
)
await pip_installer.install(requirements_path=requirements_path)
await pip_installer.install(
requirements_path=requirements_path,
allow_target_upgrade=bool(install_plan.version_mismatch_names),
)
return
logger.info(
@@ -148,7 +167,10 @@ async def _install_requirements_with_precheck(
with _temporary_filtered_requirements_file(
install_lines=install_plan.install_lines,
) as filtered_requirements_path:
await pip_installer.install(requirements_path=filtered_requirements_path)
await pip_installer.install(
requirements_path=filtered_requirements_path,
allow_target_upgrade=bool(install_plan.version_mismatch_names),
)
class PluginManager:
@@ -332,33 +354,106 @@ class PluginManager:
logger.exception(str(dependency_error))
raise dependency_error from e
@staticmethod
def _resolve_import_dependency_recovery_state(
requirements_path: str,
*,
reserved: bool,
) -> ImportDependencyRecoveryState:
if reserved or not os.path.exists(requirements_path):
return ImportDependencyRecoveryState(ImportDependencyRecoveryMode.DISABLED)
install_plan = plan_missing_requirements_install(requirements_path)
if install_plan is None:
return ImportDependencyRecoveryState(
ImportDependencyRecoveryMode.RECOVER_ON_FAILURE
)
if install_plan.version_mismatch_names:
return ImportDependencyRecoveryState(
ImportDependencyRecoveryMode.REINSTALL_ON_FAILURE,
install_plan=install_plan,
)
return ImportDependencyRecoveryState(
ImportDependencyRecoveryMode.PRELOAD_AND_RECOVER,
install_plan=install_plan,
)
@staticmethod
def _try_import_from_installed_dependencies(
path: str,
module_str: str,
root_dir_name: str,
requirements_path: str,
import_exc: Exception,
) -> ModuleType | None:
try:
logger.info(
f"插件 {root_dir_name} 导入失败,尝试从已安装依赖恢复: {import_exc!s}"
)
pip_installer.prefer_installed_dependencies(
requirements_path=requirements_path
)
module = __import__(path, fromlist=[module_str])
logger.info(
f"插件 {root_dir_name} 已从 site-packages 恢复依赖,跳过重新安装。"
)
return module
except (ImportError, ModuleNotFoundError) as recover_exc:
logger.info(
f"插件 {root_dir_name} 已安装依赖恢复失败,将重新安装依赖: {recover_exc!s}"
)
return None
async def _import_plugin_with_dependency_recovery(
self,
path: str,
module_str: str,
root_dir_name: str,
requirements_path: str,
*,
reserved: bool = False,
) -> ModuleType:
recovery_state = self._resolve_import_dependency_recovery_state(
requirements_path,
reserved=reserved,
)
if recovery_state.mode is ImportDependencyRecoveryMode.PRELOAD_AND_RECOVER:
try:
pip_installer.prefer_installed_dependencies(
requirements_path=requirements_path
)
except Exception as preload_exc:
logger.info(
f"插件 {root_dir_name} 预加载已安装依赖失败,将继续常规导入: {preload_exc!s}"
)
try:
return __import__(path, fromlist=[module_str])
except (ModuleNotFoundError, ImportError) as import_exc:
if os.path.exists(requirements_path):
try:
logger.info(
f"插件 {root_dir_name} 导入失败,尝试从已安装依赖恢复: {import_exc!s}"
)
pip_installer.prefer_installed_dependencies(
requirements_path=requirements_path
)
module = __import__(path, fromlist=[module_str])
logger.info(
f"插件 {root_dir_name} 已从 site-packages 恢复依赖,跳过重新安装。"
)
return module
except Exception as recover_exc:
logger.info(
f"插件 {root_dir_name} 已安装依赖恢复失败,将重新安装依赖: {recover_exc!s}"
)
except ModuleNotFoundError as import_exc:
if recovery_state.mode in {
ImportDependencyRecoveryMode.PRELOAD_AND_RECOVER,
ImportDependencyRecoveryMode.RECOVER_ON_FAILURE,
}:
recovered_module = self._try_import_from_installed_dependencies(
path,
module_str,
root_dir_name,
requirements_path,
import_exc,
)
if recovered_module is not None:
return recovered_module
elif (
recovery_state.mode is ImportDependencyRecoveryMode.REINSTALL_ON_FAILURE
):
assert recovery_state.install_plan is not None
logger.info(
"插件 %s 预检查检测到版本不匹配,跳过已安装依赖恢复: %s",
root_dir_name,
sorted(recovery_state.install_plan.version_mismatch_names),
)
await self._check_plugin_dept_update(target_plugin=root_dir_name)
return __import__(path, fromlist=[module_str])
@@ -486,7 +581,7 @@ class PluginManager:
f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。",
)
if current_version not in specifier:
if not specifier.contains(current_version, prereleases=True):
return (
False,
f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}",
@@ -788,6 +883,7 @@ class PluginManager:
module_str=module_str,
root_dir_name=root_dir_name,
requirements_path=requirements_path,
reserved=reserved,
)
except Exception as e:
error_trace = traceback.format_exc()

View File

@@ -0,0 +1,55 @@
from .fs import (
FileDownloadTool,
FileEditTool,
FileReadTool,
FileUploadTool,
FileWriteTool,
GrepTool,
)
from .python import LocalPythonTool, PythonTool
from .shell import ExecuteShellTool
from .shipyard_neo import (
AnnotateExecutionTool,
BrowserBatchExecTool,
BrowserExecTool,
CreateSkillCandidateTool,
CreateSkillPayloadTool,
EvaluateSkillCandidateTool,
GetExecutionHistoryTool,
GetSkillPayloadTool,
ListSkillCandidatesTool,
ListSkillReleasesTool,
PromoteSkillCandidateTool,
RollbackSkillReleaseTool,
RunBrowserSkillTool,
SyncSkillReleaseTool,
)
from .util import check_admin_permission, normalize_umo_for_workspace
__all__ = [
"AnnotateExecutionTool",
"BrowserBatchExecTool",
"BrowserExecTool",
"CreateSkillCandidateTool",
"CreateSkillPayloadTool",
"EvaluateSkillCandidateTool",
"ExecuteShellTool",
"FileDownloadTool",
"FileEditTool",
"FileReadTool",
"FileUploadTool",
"FileWriteTool",
"GetExecutionHistoryTool",
"GetSkillPayloadTool",
"GrepTool",
"ListSkillCandidatesTool",
"ListSkillReleasesTool",
"LocalPythonTool",
"PromoteSkillCandidateTool",
"PythonTool",
"RollbackSkillReleaseTool",
"RunBrowserSkillTool",
"SyncSkillReleaseTool",
"normalize_umo_for_workspace",
"check_admin_permission",
]

View File

@@ -0,0 +1,749 @@
"""Filesystem tool audit.
Tool exposure from the main agent:
- Local runtime exposes `astrbot_read_file_tool`, `astrbot_file_write_tool`,
`astrbot_file_edit_tool`, and `astrbot_grep_tool`.
- Sandbox runtime exposes `astrbot_upload_file`, `astrbot_download_file`,
`astrbot_read_file_tool`, `astrbot_file_write_tool`,
`astrbot_file_edit_tool`, and `astrbot_grep_tool`.
Behavior when `provider_settings.computer_use_require_admin=True`:
- Admin + local: read/write/edit/grep are not path-restricted by this module;
access depends on the local runtime implementation and host OS permissions.
Upload and download tools are defined here, but `LocalBooter` does not
implement them and the main agent does not expose them in local mode.
- Member + local: read/write/edit/grep are restricted to `data/skills`,
`data/workspaces/{normalized_umo}`, and `/tmp/.astrbot`. Upload/download are
denied by `check_admin_permission` if invoked.
- Admin + sandbox: read/write/edit/grep are not path-restricted by this
module;
sandbox filesystem boundaries are enforced by the sandbox runtime. Upload and
download are allowed.
- Member + sandbox: read/write/edit/grep are also not path-restricted by this
module. Upload/download are denied by `check_admin_permission` if invoked.
When `computer_use_require_admin=False`, member behavior in this module matches
admin behavior.
Local path resolution rule:
- In local runtime, relative paths are resolved under
`data/workspaces/{normalized_umo}`.
- In sandbox runtime, relative paths are passed through unchanged.
"""
import os
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from astrbot.api import FunctionTool, logger
from astrbot.api.event import MessageChain
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter
from astrbot.core.computer.file_read_utils import read_file_tool_result
from astrbot.core.message.components import File
from astrbot.core.utils.astrbot_path import (
get_astrbot_skills_path,
get_astrbot_system_tmp_path,
get_astrbot_temp_path,
)
from ..registry import builtin_tool
from . import util as computer_util
from .util import (
check_admin_permission,
is_local_runtime,
normalize_umo_for_workspace,
)
_COMPUTER_RUNTIME_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": ("local", "sandbox"),
}
_SANDBOX_RUNTIME_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": "sandbox",
}
def _restricted_env_path_labels(umo: str) -> list[str]:
"""Labels for the allowed directories in a local(not sandbox) and restricted(not admin) environment"""
normalized_umo = normalize_umo_for_workspace(umo)
return [
"data/skills",
f"data/workspaces/{normalized_umo}",
get_astrbot_system_tmp_path(),
get_astrbot_temp_path(),
]
def get_astrbot_workspaces_path() -> str:
"""Compatibility wrapper for tests and older module-level monkeypatches."""
return computer_util.get_astrbot_workspaces_path()
def _workspace_root(umo: str) -> Path:
"""Workspace root that follows both util-level and fs-level getter monkeypatches."""
normalized_umo = normalize_umo_for_workspace(umo)
return (Path(get_astrbot_workspaces_path()) / normalized_umo).resolve(strict=False)
def _read_allowed_roots(umo: str) -> tuple[Path, ...]:
"""Non-admin users can only read files within these directories (and their subdirectories)"""
return (
Path(get_astrbot_skills_path()).resolve(strict=False),
_workspace_root(umo),
Path(get_astrbot_system_tmp_path()).resolve(strict=False),
Path(get_astrbot_temp_path()).resolve(strict=False),
)
def _is_restricted_env(context: ContextWrapper[AstrAgentContext]) -> bool:
if not is_local_runtime(context):
return False
cfg = context.context.context.get_config(
umo=context.context.event.unified_msg_origin
)
provider_settings = cfg.get("provider_settings", {})
require_admin = provider_settings.get("computer_use_require_admin", True)
return require_admin and context.context.event.role != "admin"
def _resolve_tool_path(path: str, *, local_env: bool, umo: str) -> str:
normalized_path = path.strip()
if not normalized_path:
return normalized_path
candidate = Path(normalized_path).expanduser()
if candidate.is_absolute():
return str(candidate.resolve(strict=False))
if local_env:
return str((_workspace_root(umo) / candidate).resolve(strict=False))
return normalized_path
def _resolve_user_path(path: str, *, local_env: bool, umo: str) -> Path:
candidate = Path(path).expanduser()
if candidate.is_absolute():
return candidate.resolve(strict=False)
if local_env:
return (_workspace_root(umo) / candidate).resolve(strict=False)
return (Path.cwd() / candidate).resolve(strict=False)
def _is_path_within_allowed_roots(path: str, umo: str) -> bool:
resolved = _resolve_user_path(path, local_env=True, umo=umo)
return any(
resolved == allowed_root or resolved.is_relative_to(allowed_root)
for allowed_root in _read_allowed_roots(umo)
)
def _normalize_rw_path(
path: str,
*,
restricted: bool,
local_env: bool,
umo: str,
) -> str:
normalized_path = _resolve_tool_path(path, local_env=local_env, umo=umo)
if not normalized_path:
raise ValueError("`path` must be a non-empty string.")
if restricted and not _is_path_within_allowed_roots(normalized_path, umo):
allowed = ", ".join(_restricted_env_path_labels(umo))
raise PermissionError(
"Read access is restricted for this user. "
f"Allowed directories: {allowed}. Blocked path: {normalized_path}."
)
return normalized_path
def _decode_escaped_text(value: str) -> str:
"""Decode common escaped control sequences used in tool arguments."""
return (
value.replace("\\r\\n", "\n")
.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\t", "\t")
)
@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG)
@dataclass
class FileReadTool(FunctionTool):
name: str = "astrbot_file_read_tool"
description: str = "read file content."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path of the file to read. If relative, will be in workspace root.",
},
"offset": {
"type": "integer",
"description": "Optional line offset to start reading from. 0-based index.",
"minimum": 0,
},
"limit": {
"type": "integer",
"description": "Optional maximum number of lines to read.",
"minimum": 1,
},
},
"required": ["path"],
}
)
def _validate_read_window(
self,
offset: int | None,
limit: int | None,
) -> tuple[int | None, int | None]:
if offset is not None and offset < 0:
raise ValueError("`offset` must be greater than or equal to 0.")
if limit is not None and limit < 1:
raise ValueError("`limit` must be greater than or equal to 1.")
return offset, limit
async def call(
self,
context: ContextWrapper[AstrAgentContext],
path: str,
offset: int | None = None,
limit: int | None = None,
) -> ToolExecResult:
local_env = is_local_runtime(context)
restricted = _is_restricted_env(context)
try:
normalized_path = (
_normalize_rw_path(
path,
restricted=restricted,
local_env=local_env,
umo=context.context.event.unified_msg_origin,
)
if local_env
else path.strip()
)
if not normalized_path:
raise ValueError("`path` must be a non-empty string.")
offset, limit = self._validate_read_window(offset, limit)
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
return await read_file_tool_result(
sb,
local_mode=local_env,
path=normalized_path,
offset=offset,
limit=limit,
workspace_dir=(
str(_workspace_root(context.context.event.unified_msg_origin))
if local_env
else None
),
)
except PermissionError as exc:
return f"Error: {exc}"
except Exception as exc:
logger.error(f"Error reading file: {exc}")
return f"Error reading file: {exc}"
@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG)
@dataclass
class FileWriteTool(FunctionTool):
name: str = "astrbot_file_write_tool"
description: str = "Write UTF-8 text content to a file."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path of the file to write. If relative, will be in workspace root.",
},
"content": {
"type": "string",
"description": "The content to write to the file",
},
},
"required": ["path", "content"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
path: str,
content: str,
) -> ToolExecResult:
local_env = is_local_runtime(context)
restricted = _is_restricted_env(context)
try:
normalized_path = (
_normalize_rw_path(
path,
restricted=restricted,
local_env=local_env,
umo=context.context.event.unified_msg_origin,
)
if local_env
else path.strip()
)
if not normalized_path:
raise ValueError("`path` must be a non-empty string.")
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
result = await sb.fs.write_file(
path=normalized_path,
content=content,
mode="w",
encoding="utf-8",
)
if not result.get("success", False):
error_detail = str(result.get("error", "") or "").strip()
return (
"Error writing file: "
f"{error_detail or 'unknown filesystem write error'}"
)
return f"File written successfully: {normalized_path}"
except PermissionError as exc:
return f"Error: {exc}"
except Exception as exc:
logger.error(f"Error writing file: {exc}")
return f"Error writing file: {exc}"
@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG)
@dataclass
class FileEditTool(FunctionTool):
name: str = "astrbot_file_edit_tool"
description: str = "Editing files."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path of the file to edit. If relative, will be in workspace root.",
},
"old": {
"type": "string",
"description": "The exact old text to replace.",
},
"new": {
"type": "string",
"description": "The replacement text.",
},
"replace_all": {
"type": "boolean",
"description": "Whether to replace all matches. Defaults to false.",
},
},
"required": ["path", "old", "new"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
path: str,
old: str,
new: str,
replace_all: bool = False,
) -> ToolExecResult:
umo = str(context.context.event.unified_msg_origin)
local_env = is_local_runtime(context)
restricted = _is_restricted_env(context)
try:
normalized_path = (
_normalize_rw_path(
path,
restricted=restricted,
local_env=local_env,
umo=umo,
)
if local_env
else path.strip()
)
if not normalized_path:
raise ValueError("`path` must be a non-empty string.")
normalized_old = _decode_escaped_text(old)
normalized_new = _decode_escaped_text(new)
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
result = await sb.fs.edit_file(
path=normalized_path,
old_string=normalized_old,
new_string=normalized_new,
replace_all=replace_all,
encoding="utf-8",
)
if not result.get("success", False):
error_detail = str(result.get("error", "") or "").strip()
return (
"Error editing file: "
f"{error_detail or 'unknown filesystem edit error'}"
)
replacements = int(result.get("replacements", 0) or 0)
mode_text = "all matches" if replace_all else "first match"
return (
f"Edited {normalized_path}. "
f"Replaced {replacements} occurrence(s) using {mode_text} mode."
)
except PermissionError as exc:
return f"Error: {exc}"
except Exception as exc:
logger.error(f"Error editing file: {exc}")
return f"Error editing file: {exc}"
@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG)
@dataclass
class GrepTool(FunctionTool):
name: str = "astrbot_grep_tool"
description: str = "Search and read file contents using ripgrep."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "The expression pattern to search for in file contents.",
},
"path": {
"type": "string",
"description": "File or directory to search in (rg PATH). If relative, will be in workspace root.",
},
"glob": {
"type": "string",
"description": "Optional glob filter such as `*.py`, `*.{ts,tsx}`.",
},
"-A": {
"type": "integer",
"description": "Number of trailing context lines to include after each match.",
"minimum": 0,
},
"-B": {
"type": "integer",
"description": "Number of leading context lines to include before each match.",
"minimum": 0,
},
"-C": {
"type": "integer",
"description": "Number of leading and trailing context lines to include around each match.",
"minimum": 0,
},
"result_limit": {
"type": "integer",
"description": "Maximum number of result groups returned by the tool. Defaults to 100.",
"minimum": 1,
},
},
"required": ["pattern"],
}
)
def _resolve_context_options(
self,
after_context: int | None,
before_context: int | None,
context: int | None,
) -> tuple[int | None, int | None]:
if context is not None and context < 0:
raise ValueError("`-C` must be greater than or equal to 0.")
if after_context is not None and after_context < 0:
raise ValueError("`-A` must be greater than or equal to 0.")
if before_context is not None and before_context < 0:
raise ValueError("`-B` must be greater than or equal to 0.")
resolved_after = context if after_context is None else after_context
resolved_before = context if before_context is None else before_context
return resolved_after, resolved_before
def _split_output_groups(self, output: str, *, has_context: bool) -> list[str]:
if not output.strip():
return []
if not has_context:
return [f"{line}\n" for line in output.splitlines() if line.strip()]
groups: list[str] = []
current: list[str] = []
for line in output.splitlines(keepends=True):
if line.strip() == "--":
if current:
groups.append("".join(current))
current = []
continue
if not line.strip():
continue
current.append(line)
if current:
groups.append("".join(current))
return groups
def _apply_result_limit(
self,
output: str,
*,
result_limit: int,
has_context: bool,
) -> str:
if result_limit < 1:
raise ValueError("`result_limit` must be greater than or equal to 1.")
groups = self._split_output_groups(output, has_context=has_context)
if len(groups) <= result_limit:
return output if output.strip() else "No matches found."
limited_output = "".join(groups[:result_limit]).rstrip()
return f"{limited_output}\n\n[Truncated to first {result_limit} result groups.]"
def _normalize_search_paths(
self,
path: str | None,
*,
restricted: bool,
local_env: bool,
umo: str,
) -> list[str]:
normalized = (
[_resolve_tool_path(path, local_env=local_env, umo=umo)] if path else []
)
if not normalized:
if restricted:
return [str(root) for root in _read_allowed_roots(umo)]
if local_env:
return [str(_workspace_root(umo))]
return ["."]
if restricted:
disallowed = [
path
for path in normalized
if not _is_path_within_allowed_roots(path, umo)
]
if disallowed:
allowed = ", ".join(_restricted_env_path_labels(umo))
blocked = ", ".join(disallowed)
raise PermissionError(
"Read access is restricted for this user. "
f"Allowed directories: {allowed}. Blocked paths: {blocked}."
)
return normalized
async def call(
self,
context: ContextWrapper[AstrAgentContext],
pattern: str,
path: str | None = None,
glob: str | None = None,
result_limit: int = 100,
**kwargs,
) -> ToolExecResult:
normalized_pattern = pattern.strip()
if not normalized_pattern:
return "Error: `pattern` must be a non-empty string."
local_env = is_local_runtime(context)
restricted = _is_restricted_env(context)
try:
search_paths = (
self._normalize_search_paths(
path,
restricted=restricted,
local_env=local_env,
umo=context.context.event.unified_msg_origin,
)
if local_env
else ([path.strip()] if path and path.strip() else ["."])
)
after_context, before_context = self._resolve_context_options(
kwargs.get("-A"),
kwargs.get("-B"),
kwargs.get("-C"),
)
has_context = (after_context or 0) > 0 or (before_context or 0) > 0
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
contents: list[str] = []
for search_path in search_paths:
result = await sb.fs.search_files(
pattern=normalized_pattern,
path=search_path,
glob=glob,
after_context=after_context,
before_context=before_context,
)
if not result.get("success", False):
error_detail = str(result.get("error", "") or "").strip()
logger.error("GrepTool search failed: %s", error_detail)
return (
"Error searching files: "
f"{error_detail or 'unknown filesystem search error'}"
)
content = str(result.get("content", "") or "")
if content:
contents.append(content)
return self._apply_result_limit(
"".join(contents),
result_limit=result_limit,
has_context=has_context,
)
except PermissionError as exc:
return f"Error: {exc}"
except Exception as exc:
logger.error(f"Error searching files: {exc}")
return f"Error searching files: {exc}"
@builtin_tool(config=_SANDBOX_RUNTIME_TOOL_CONFIG)
@dataclass
class FileUploadTool(FunctionTool):
name: str = "astrbot_upload_file"
description: str = (
"Transfer a file FROM the host machine INTO the sandbox so that sandbox "
"code can access it. Use this when the user sends/attaches a file and you "
"need to process it inside the sandbox. The local_path must point to an "
"existing file on the host filesystem."
)
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"local_path": {
"type": "string",
"description": "Absolute path to the file on the host filesystem that will be copied into the sandbox.",
},
# "remote_path": {
# "type": "string",
# "description": "The filename to use in the sandbox. If not provided, file will be saved to the working directory with the same name as the local file.",
# },
},
"required": ["local_path"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
local_path: str,
) -> str | None:
if permission_error := check_admin_permission(context, "File upload/download"):
return permission_error
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
# Check if file exists
if not os.path.exists(local_path):
return f"Error: File does not exist: {local_path}"
if not os.path.isfile(local_path):
return f"Error: Path is not a file: {local_path}"
# Use basename if sandbox_filename is not provided
remote_path = os.path.basename(local_path)
# Upload file to sandbox
result = await sb.upload_file(local_path, remote_path)
logger.debug(f"Upload result: {result}")
success = result.get("success", False)
if not success:
return f"Error uploading file: {result.get('message', 'Unknown error')}"
file_path = result.get("file_path", "")
logger.info(f"File {local_path} uploaded to sandbox at {file_path}")
return f"File uploaded successfully to {file_path}"
except Exception as e:
logger.error(f"Error uploading file {local_path}: {e}")
return f"Error uploading file: {str(e)}"
@builtin_tool(config=_SANDBOX_RUNTIME_TOOL_CONFIG)
@dataclass
class FileDownloadTool(FunctionTool):
name: str = "astrbot_download_file"
description: str = (
"Transfer a file FROM the sandbox OUT to the host and optionally send it "
"to the user. Use this ONLY when the user asks to retrieve/export a file "
"that was created or modified inside the sandbox."
)
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"remote_path": {
"type": "string",
"description": "Path of the file inside the sandbox to copy out to the host.",
},
"also_send_to_user": {
"type": "boolean",
"description": "Whether to also send the downloaded file to the user via message. Defaults to true.",
},
},
"required": ["remote_path"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
remote_path: str,
also_send_to_user: bool = True,
) -> ToolExecResult:
if permission_error := check_admin_permission(context, "File upload/download"):
return permission_error
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
name = os.path.basename(remote_path)
local_path = os.path.join(
get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}"
)
# Download file from sandbox
await sb.download_file(remote_path, local_path)
logger.info(f"File {remote_path} downloaded from sandbox to {local_path}")
if also_send_to_user:
try:
name = os.path.basename(local_path)
await context.context.event.send(
MessageChain(chain=[File(name=name, file=local_path)])
)
except Exception as e:
logger.error(f"Error sending file message: {e}")
# remove
# try:
# os.remove(local_path)
# except Exception as e:
# logger.error(f"Error removing temp file {local_path}: {e}")
return f"File downloaded successfully to {local_path} and sent to user."
return f"File downloaded successfully to {local_path}"
except Exception as e:
logger.error(f"Error downloading file {remote_path}: {e}")
return f"Error downloading file: {str(e)}"

View File

@@ -8,10 +8,18 @@ from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent
from astrbot.core.computer.computer_client import get_booter, get_local_booter
from astrbot.core.computer.tools.permissions import check_admin_permission
from astrbot.core.message.message_event_result import MessageChain
from ..registry import builtin_tool
from .util import check_admin_permission
_OS_NAME = platform.system()
_SANDBOX_PYTHON_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": "sandbox",
}
_LOCAL_PYTHON_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": "local",
}
param_schema = {
"type": "object",
@@ -61,6 +69,7 @@ async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult
return resp
@builtin_tool(config=_SANDBOX_PYTHON_TOOL_CONFIG)
@dataclass
class PythonTool(FunctionTool):
name: str = "astrbot_execute_ipython"
@@ -83,6 +92,7 @@ class PythonTool(FunctionTool):
return f"Error executing code: {str(e)}"
@builtin_tool(config=_LOCAL_PYTHON_TOOL_CONFIG)
@dataclass
class LocalPythonTool(FunctionTool):
name: str = "astrbot_execute_python"

View File

@@ -5,11 +5,17 @@ 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.computer.computer_client import get_booter
from ..computer_client import get_booter, get_local_booter
from .permissions import check_admin_permission
from ..registry import builtin_tool
from .util import check_admin_permission, is_local_runtime, workspace_root
_COMPUTER_RUNTIME_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": ("local", "sandbox"),
}
@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG)
@dataclass
class ExecuteShellTool(FunctionTool):
name: str = "astrbot_execute_shell"
@@ -38,8 +44,6 @@ class ExecuteShellTool(FunctionTool):
}
)
is_local: bool = False
async def call(
self,
context: ContextWrapper[AstrAgentContext],
@@ -50,15 +54,25 @@ class ExecuteShellTool(FunctionTool):
if permission_error := check_admin_permission(context, "Shell execution"):
return permission_error
if self.is_local:
sb = get_local_booter()
else:
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
result = await sb.shell.exec(command, background=background, env=env)
return json.dumps(result)
cwd: str | None = None
if is_local_runtime(context):
current_workspace_root = workspace_root(
context.context.event.unified_msg_origin
)
current_workspace_root.mkdir(parents=True, exist_ok=True)
cwd = str(current_workspace_root)
result = await sb.shell.exec(
command,
cwd=cwd,
background=background,
env=env,
)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
return f"Error executing command: {str(e)}"

View File

@@ -1,5 +1,4 @@
from .browser import BrowserBatchExecTool, BrowserExecTool, RunBrowserSkillTool
from .fs import FileDownloadTool, FileUploadTool
from .neo_skills import (
AnnotateExecutionTool,
CreateSkillCandidateTool,
@@ -13,27 +12,20 @@ from .neo_skills import (
RollbackSkillReleaseTool,
SyncSkillReleaseTool,
)
from .python import LocalPythonTool, PythonTool
from .shell import ExecuteShellTool
__all__ = [
"BrowserExecTool",
"BrowserBatchExecTool",
"RunBrowserSkillTool",
"GetExecutionHistoryTool",
"AnnotateExecutionTool",
"CreateSkillPayloadTool",
"GetSkillPayloadTool",
"BrowserBatchExecTool",
"BrowserExecTool",
"CreateSkillCandidateTool",
"ListSkillCandidatesTool",
"CreateSkillPayloadTool",
"EvaluateSkillCandidateTool",
"PromoteSkillCandidateTool",
"GetExecutionHistoryTool",
"GetSkillPayloadTool",
"ListSkillCandidatesTool",
"ListSkillReleasesTool",
"PromoteSkillCandidateTool",
"RollbackSkillReleaseTool",
"RunBrowserSkillTool",
"SyncSkillReleaseTool",
"FileUploadTool",
"PythonTool",
"LocalPythonTool",
"ExecuteShellTool",
"FileDownloadTool",
]

View File

@@ -6,9 +6,14 @@ 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.computer.computer_client import get_booter
from astrbot.core.tools.computer_tools.util import check_admin_permission
from astrbot.core.tools.registry import builtin_tool
from ..computer_client import get_booter
from .permissions import check_admin_permission
_SHIPYARD_NEO_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "shipyard_neo",
}
def _to_json(data: Any) -> str:
@@ -29,6 +34,7 @@ async def _get_browser_component(context: ContextWrapper[AstrAgentContext]) -> A
return browser
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
@dataclass
class BrowserExecTool(FunctionTool):
name: str = "astrbot_execute_browser"
@@ -86,6 +92,7 @@ class BrowserExecTool(FunctionTool):
return f"Error executing browser command: {str(e)}"
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
@dataclass
class BrowserBatchExecTool(FunctionTool):
name: str = "astrbot_execute_browser_batch"
@@ -150,6 +157,7 @@ class BrowserBatchExecTool(FunctionTool):
return f"Error executing browser batch command: {str(e)}"
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
@dataclass
class RunBrowserSkillTool(FunctionTool):
name: str = "astrbot_run_browser_skill"

View File

@@ -7,10 +7,15 @@ 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.computer.computer_client import get_booter
from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager
from astrbot.core.tools.computer_tools.util import check_admin_permission
from astrbot.core.tools.registry import builtin_tool
from ..computer_client import get_booter
from .permissions import check_admin_permission
_SHIPYARD_NEO_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "shipyard_neo",
}
def _to_jsonable(model_like: Any) -> Any:
@@ -64,6 +69,7 @@ class NeoSkillToolBase(FunctionTool):
return f"{self.error_prefix} {error_action}: {str(e)}"
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
@dataclass
class GetExecutionHistoryTool(NeoSkillToolBase):
name: str = "astrbot_get_execution_history"
@@ -110,6 +116,7 @@ class GetExecutionHistoryTool(NeoSkillToolBase):
)
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
@dataclass
class AnnotateExecutionTool(NeoSkillToolBase):
name: str = "astrbot_annotate_execution"
@@ -147,6 +154,7 @@ class AnnotateExecutionTool(NeoSkillToolBase):
)
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
@dataclass
class CreateSkillPayloadTool(NeoSkillToolBase):
name: str = "astrbot_create_skill_payload"
@@ -194,6 +202,7 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
)
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
@dataclass
class GetSkillPayloadTool(NeoSkillToolBase):
name: str = "astrbot_get_skill_payload"
@@ -220,6 +229,7 @@ class GetSkillPayloadTool(NeoSkillToolBase):
)
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
@dataclass
class CreateSkillCandidateTool(NeoSkillToolBase):
name: str = "astrbot_create_skill_candidate"
@@ -273,6 +283,7 @@ class CreateSkillCandidateTool(NeoSkillToolBase):
)
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
@dataclass
class ListSkillCandidatesTool(NeoSkillToolBase):
name: str = "astrbot_list_skill_candidates"
@@ -310,6 +321,7 @@ class ListSkillCandidatesTool(NeoSkillToolBase):
)
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
@dataclass
class EvaluateSkillCandidateTool(NeoSkillToolBase):
name: str = "astrbot_evaluate_skill_candidate"
@@ -350,6 +362,7 @@ class EvaluateSkillCandidateTool(NeoSkillToolBase):
)
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
@dataclass
class PromoteSkillCandidateTool(NeoSkillToolBase):
name: str = "astrbot_promote_skill_candidate"
@@ -420,6 +433,7 @@ class PromoteSkillCandidateTool(NeoSkillToolBase):
return f"Error promoting skill candidate: {str(e)}"
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
@dataclass
class ListSkillReleasesTool(NeoSkillToolBase):
name: str = "astrbot_list_skill_releases"
@@ -460,6 +474,7 @@ class ListSkillReleasesTool(NeoSkillToolBase):
)
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
@dataclass
class RollbackSkillReleaseTool(NeoSkillToolBase):
name: str = "astrbot_rollback_skill_release"
@@ -486,6 +501,7 @@ class RollbackSkillReleaseTool(NeoSkillToolBase):
)
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
@dataclass
class SyncSkillReleaseTool(NeoSkillToolBase):
name: str = "astrbot_sync_skill_release"

View File

@@ -1,5 +1,29 @@
import re
from pathlib import Path
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path
def normalize_umo_for_workspace(umo: str) -> str:
normalized = re.sub(r"[^A-Za-z0-9._-]+", "_", umo.strip())
return normalized or "unknown"
def workspace_root(umo: str) -> Path:
"""Root directory for relative paths in local runtime"""
normalized_umo = normalize_umo_for_workspace(umo)
return (Path(get_astrbot_workspaces_path()) / normalized_umo).resolve(strict=False)
def is_local_runtime(context: ContextWrapper[AstrAgentContext]) -> bool:
cfg = context.context.context.get_config(
umo=context.context.event.unified_msg_origin
)
provider_settings = cfg.get("provider_settings", {})
runtime = str(provider_settings.get("computer_use_runtime", "local"))
return runtime == "local"
def check_admin_permission(

View File

@@ -9,6 +9,10 @@ from astrbot.core.agent.tool import FunctionTool, ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.tools.registry import builtin_tool
_CRON_TOOL_CONFIG = {
"provider_settings.proactive_capability.add_cron_tools": True,
}
def _extract_job_session(job: Any) -> str | None:
payload = getattr(job, "payload", None)
@@ -18,40 +22,58 @@ def _extract_job_session(job: Any) -> str | None:
return str(session) if session is not None else None
@builtin_tool
def _parse_run_at(run_at: Any) -> datetime | None:
if run_at in (None, ""):
return None
return datetime.fromisoformat(str(run_at))
@builtin_tool(config=_CRON_TOOL_CONFIG)
@dataclass
class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
name: str = "create_future_task"
class FutureTaskTool(FunctionTool[AstrAgentContext]):
name: str = "future_task"
description: str = (
"Create a future task for your future. Supports recurring cron expressions or one-time run_at datetime. "
"Use this when you or the user want scheduled follow-up or proactive actions."
"Manage your future tasks. "
"Use action='create' to schedule a recurring cron task or one-time run_at task. "
"Use action='edit' to update an existing task. "
"Use action='list' to inspect existing tasks. "
"Use action='delete' to remove a task by job_id."
)
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create", "edit", "delete", "list"],
"description": "Action to perform. 'list' takes no parameters. 'delete' requires only 'job_id'. 'edit' requires 'job_id' plus the fields to change.",
},
"name": {
"type": "string",
"description": "Optional task label.",
},
"cron_expression": {
"type": "string",
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *' or '0 23 * * mon-fri'). Prefer named weekdays like 'mon-fri' or 'sat,sun' instead of numeric day-of-week ranges such as '1-5' to avoid ambiguity across cron implementations.",
},
"run_at": {
"type": "string",
"description": "ISO datetime for one-time execution, e.g., 2026-02-02T08:00:00+08:00. Use with run_once=true.",
"description": "Cron expression for a recurring schedule, e.g. '0 8 * * *' or '0 23 * * mon-fri'. Prefer named weekdays like 'mon-fri' or 'sat,sun' over numeric ranges like '1-5'.",
},
"note": {
"type": "string",
"description": "Detailed instructions for your future agent to execute when it wakes.",
},
"name": {
"type": "string",
"description": "Optional label to recognize this future task.",
},
"run_once": {
"type": "boolean",
"description": "If true, the task will run only once and then be deleted. Use run_at to specify the time.",
"description": "Run only once and delete after execution. Use with run_at.",
},
"run_at": {
"type": "string",
"description": "ISO datetime for one-time execution, e.g. 2026-02-02T08:00:00+08:00.",
},
"job_id": {
"type": "string",
"description": "Task ID. Required for 'delete' and 'edit'.",
},
},
"required": ["note"],
"required": ["action"],
}
)
@@ -62,130 +84,152 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
if cron_mgr is None:
return "error: cron manager is not available."
cron_expression = kwargs.get("cron_expression")
run_at = kwargs.get("run_at")
run_once = bool(kwargs.get("run_once", False))
note = str(kwargs.get("note", "")).strip()
name = str(kwargs.get("name") or "").strip() or "active_agent_task"
action = str(kwargs.get("action") or "").strip().lower()
if action == "create":
cron_expression = kwargs.get("cron_expression")
run_at = kwargs.get("run_at")
run_once = bool(kwargs.get("run_once", False))
note = str(kwargs.get("note", "")).strip()
name = str(kwargs.get("name") or "").strip() or "active_agent_task"
if not note:
return "error: note is required."
if run_once and not run_at:
return "error: run_at is required when run_once=true."
if (not run_once) and not cron_expression:
return "error: cron_expression is required when run_once=false."
if run_once and cron_expression:
cron_expression = None
run_at_dt = None
if run_at:
if not note:
return "error: note is required when action=create."
if run_once and not run_at:
return "error: run_at is required when run_once=true."
if (not run_once) and not cron_expression:
return "error: cron_expression is required when run_once=false."
if run_once and cron_expression:
cron_expression = None
try:
run_at_dt = datetime.fromisoformat(str(run_at))
run_at_dt = _parse_run_at(run_at)
except Exception:
return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00"
payload = {
"session": context.context.event.unified_msg_origin,
"sender_id": context.context.event.get_sender_id(),
"note": note,
"origin": "tool",
}
payload = {
"session": context.context.event.unified_msg_origin,
"sender_id": context.context.event.get_sender_id(),
"note": note,
"origin": "tool",
}
job = await cron_mgr.add_active_job(
name=name,
cron_expression=str(cron_expression) if cron_expression else None,
payload=payload,
description=note,
run_once=run_once,
run_at=run_at_dt,
)
next_run = job.next_run_time or run_at_dt
suffix = (
f"one-time at {next_run}"
if run_once
else f"expression '{cron_expression}' (next {next_run})"
)
return f"Scheduled future task {job.job_id} ({job.name}) {suffix}."
@builtin_tool
@dataclass
class DeleteCronJobTool(FunctionTool[AstrAgentContext]):
name: str = "delete_future_task"
description: str = "Delete a future task (cron job) by its job_id."
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "The job_id returned when the job was created.",
}
},
"required": ["job_id"],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
cron_mgr = context.context.context.cron_manager
if cron_mgr is None:
return "error: cron manager is not available."
current_umo = context.context.event.unified_msg_origin
job_id = kwargs.get("job_id")
if not job_id:
return "error: job_id is required."
job = await cron_mgr.db.get_cron_job(str(job_id))
if not job:
return f"error: cron job {job_id} not found."
if _extract_job_session(job) != current_umo:
return "error: you can only delete future tasks in the current umo."
await cron_mgr.delete_job(str(job_id))
return f"Deleted cron job {job_id}."
@builtin_tool
@dataclass
class ListCronJobsTool(FunctionTool[AstrAgentContext]):
name: str = "list_future_tasks"
description: str = "List existing future tasks (cron jobs) for inspection."
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"job_type": {
"type": "string",
"description": "Optional filter: basic or active_agent.",
}
},
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
cron_mgr = context.context.context.cron_manager
if cron_mgr is None:
return "error: cron manager is not available."
current_umo = context.context.event.unified_msg_origin
job_type = kwargs.get("job_type")
jobs = [
job
for job in await cron_mgr.list_jobs(job_type)
if _extract_job_session(job) == current_umo
]
if not jobs:
return "No cron jobs found."
lines = []
for j in jobs:
lines.append(
f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}"
job = await cron_mgr.add_active_job(
name=name,
cron_expression=str(cron_expression) if cron_expression else None,
payload=payload,
description=note,
run_once=run_once,
run_at=run_at_dt,
)
return "\n".join(lines)
next_run = job.next_run_time or run_at_dt
suffix = (
f"one-time at {next_run}"
if run_once
else f"expression '{cron_expression}' (next {next_run})"
)
return f"Scheduled future task {job.job_id} ({job.name}) {suffix}."
current_umo = context.context.event.unified_msg_origin
if action == "edit":
job_id = kwargs.get("job_id")
if not job_id:
return "error: job_id is required when action=edit."
if not any(
key in kwargs
for key in ("name", "note", "run_once", "cron_expression", "run_at")
):
return "error: no editable fields were provided."
job = await cron_mgr.db.get_cron_job(str(job_id))
if not job:
return f"error: cron job {job_id} not found."
if _extract_job_session(job) != current_umo:
return "error: you can only edit future tasks in the current umo."
payload = dict(job.payload) if isinstance(job.payload, dict) else {}
updates: dict[str, Any] = {}
if "name" in kwargs:
name = str(kwargs.get("name") or "").strip()
if not name:
return "error: name cannot be empty when action=edit."
updates["name"] = name
if "note" in kwargs:
note = str(kwargs.get("note") or "").strip()
if not note:
return "error: note cannot be empty when action=edit."
payload["note"] = note
updates["description"] = note
current_run_at = payload.get("run_at")
run_once = (
bool(kwargs["run_once"]) if "run_once" in kwargs else bool(job.run_once)
)
cron_expression = (
str(kwargs.get("cron_expression") or "").strip()
if "cron_expression" in kwargs
else job.cron_expression
)
cron_expression = cron_expression or None
try:
run_at_dt = (
_parse_run_at(kwargs.get("run_at"))
if "run_at" in kwargs
else _parse_run_at(current_run_at)
)
except Exception:
return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00"
if run_once:
if run_at_dt is None:
return "error: run_at is required when run_once=true."
cron_expression = None
payload["run_at"] = run_at_dt.isoformat()
else:
if not cron_expression:
return "error: cron_expression is required when run_once=false."
payload.pop("run_at", None)
updates["run_once"] = run_once
updates["cron_expression"] = cron_expression
updates["payload"] = payload
job = await cron_mgr.update_job(str(job_id), **updates)
if not job:
return f"error: cron job {job_id} not found."
return f"Updated future task {job.job_id} ({job.name})."
if action == "delete":
job_id = kwargs.get("job_id")
if not job_id:
return "error: job_id is required when action=delete."
job = await cron_mgr.db.get_cron_job(str(job_id))
if not job:
return f"error: cron job {job_id} not found."
if _extract_job_session(job) != current_umo:
return "error: you can only delete future tasks in the current umo."
await cron_mgr.delete_job(str(job_id))
return f"Deleted cron job {job_id}."
if action == "list":
jobs = [
job
for job in await cron_mgr.list_jobs()
if _extract_job_session(job) == current_umo
]
if not jobs:
return "No cron jobs found."
lines = []
for j in jobs:
lines.append(
f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}"
)
return "\n".join(lines)
return "error: action must be one of create, edit, delete, or list."
__all__ = [
"CreateActiveCronTool",
"DeleteCronJobTool",
"ListCronJobsTool",
"FutureTaskTool",
]

View File

@@ -9,6 +9,10 @@ from astrbot.core.knowledge_base.kb_helper import KBHelper
from astrbot.core.star.context import Context
from astrbot.core.tools.registry import builtin_tool
_KNOWLEDGE_BASE_TOOL_CONFIG = {
"kb_agentic_mode": True,
}
def check_all_kb(kb_list: list[KBHelper | None]) -> bool:
"""检查是否所有的知识库都为空"""
@@ -83,7 +87,7 @@ async def retrieve_knowledge_base(
return None
@builtin_tool
@builtin_tool(config=_KNOWLEDGE_BASE_TOOL_CONFIG)
@dataclass
class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
name: str = "astr_kb_search"

View File

@@ -1,13 +1,16 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from importlib import import_module
from typing import TypeVar
from typing import Any, TypeVar
from astrbot.core.agent.tool import FunctionTool
TFunctionTool = TypeVar("TFunctionTool", bound=type[FunctionTool])
_BUILTIN_TOOL_MODULES = (
"astrbot.core.tools.computer_tools",
"astrbot.core.tools.cron_tools",
"astrbot.core.tools.knowledge_base_tools",
"astrbot.core.tools.message_tools",
@@ -17,6 +20,182 @@ _BUILTIN_TOOL_MODULES = (
_builtin_tool_classes_by_name: dict[str, type[FunctionTool]] = {}
_builtin_tool_names_by_class: dict[type[FunctionTool], str] = {}
_builtin_tools_loaded = False
_MISSING = object()
@dataclass(frozen=True)
class BuiltinToolConfigCondition:
key: str
operator: str
expected: Any = None
message: str | None = None
def evaluate(self, config: dict[str, Any]) -> dict[str, Any]:
actual = _get_config_value(config, self.key)
if self.operator == "equals":
matched = actual == self.expected
elif self.operator == "in":
expected_values = tuple(self.expected or ())
matched = actual in expected_values
elif self.operator == "truthy":
matched = bool(actual)
elif self.operator == "custom":
matched = bool(self.expected)
else:
raise ValueError(
f"Unsupported builtin tool config operator: {self.operator}"
)
return {
"key": self.key,
"operator": self.operator,
"expected": _json_safe(self.expected),
"actual": _json_safe(None if actual is _MISSING else actual),
"matched": matched,
"message": self.message,
}
@dataclass(frozen=True)
class BuiltinToolConfigRule:
conditions: tuple[BuiltinToolConfigCondition, ...] = ()
evaluator: Callable[[dict[str, Any]], list[dict[str, Any]]] | None = None
def evaluate(self, config: dict[str, Any]) -> list[dict[str, Any]]:
if self.evaluator is not None:
return self.evaluator(config)
return [condition.evaluate(config) for condition in self.conditions]
def _get_config_value(config: dict[str, Any], key_path: str) -> Any:
current: Any = config
for segment in key_path.split("."):
if not isinstance(current, dict) or segment not in current:
return _MISSING
current = current[segment]
return current
def _json_safe(value: Any) -> Any:
if isinstance(value, tuple):
return [_json_safe(item) for item in value]
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, dict):
return {key: _json_safe(val) for key, val in value.items()}
return value
def _equals(key: str, expected: Any) -> BuiltinToolConfigCondition:
return BuiltinToolConfigCondition(key=key, operator="equals", expected=expected)
def _in(key: str, expected: tuple[Any, ...]) -> BuiltinToolConfigCondition:
return BuiltinToolConfigCondition(key=key, operator="in", expected=expected)
def _custom_condition(key: str, *, matched: bool, message: str) -> dict[str, Any]:
return {
"key": key,
"operator": "custom",
"expected": None,
"actual": None,
"matched": matched,
"message": message,
}
def _build_rule_from_config_map(
config_map: dict[str, Any],
) -> BuiltinToolConfigRule:
conditions: list[BuiltinToolConfigCondition] = []
for key, expected in config_map.items():
if isinstance(expected, tuple):
conditions.append(_in(key, expected))
else:
conditions.append(_equals(key, expected))
return BuiltinToolConfigRule(conditions=tuple(conditions))
def _evaluate_send_message_tool(config: dict[str, Any]) -> list[dict[str, Any]]:
platform_configs = config.get("platform", [])
if not isinstance(platform_configs, list):
return [
_custom_condition(
"platform",
matched=False,
message="No enabled platform in this config supports proactive messaging.",
)
]
for platform_cfg in platform_configs:
if not isinstance(platform_cfg, dict):
continue
if platform_cfg.get("enable", False) is False:
continue
platform_type = str(platform_cfg.get("type", "")).strip()
platform_id = str(platform_cfg.get("id", "")).strip() or platform_type
if not platform_type:
continue
if platform_type in {"wecom", "weixin_official_account"}:
continue
if platform_type == "wecom_ai_bot":
webhook = str(platform_cfg.get("msg_push_webhook_url", "")).strip()
if not webhook:
continue
return [
_custom_condition(
"platform[].type",
matched=True,
message=(
f"Enabled platform `{platform_id}` uses `wecom_ai_bot`, which supports proactive messaging "
"when `platform[].msg_push_webhook_url` is configured."
),
),
BuiltinToolConfigCondition(
key="platform[].msg_push_webhook_url",
operator="truthy",
).evaluate({"platform[]": {"msg_push_webhook_url": webhook}}),
]
return [
_custom_condition(
"platform[].type",
matched=True,
message=(
f"Enabled platform `{platform_id}` (`{platform_type}`) supports proactive messaging."
),
)
]
return [
_custom_condition(
"platform",
matched=False,
message="No enabled platform in this config supports proactive messaging.",
)
]
_BUILTIN_TOOL_CONFIG_RULES: dict[str, BuiltinToolConfigRule] = {}
def _register_builtin_tool_config_rule(
tool_names: tuple[str, ...],
rule: BuiltinToolConfigRule,
) -> None:
for tool_name in tool_names:
_BUILTIN_TOOL_CONFIG_RULES[tool_name] = rule
_register_builtin_tool_config_rule(
("send_message_to_user",),
BuiltinToolConfigRule(evaluator=_evaluate_send_message_tool),
)
def _resolve_builtin_tool_name(tool_cls: type[FunctionTool]) -> str:
@@ -34,18 +213,29 @@ def _resolve_builtin_tool_name(tool_cls: type[FunctionTool]) -> str:
)
def builtin_tool(tool_cls: TFunctionTool) -> TFunctionTool:
tool_name = _resolve_builtin_tool_name(tool_cls)
existing = _builtin_tool_classes_by_name.get(tool_name)
if existing is not None and existing is not tool_cls:
raise ValueError(
f"Builtin tool name conflict detected: {tool_name} is already registered by "
f"{existing.__module__}.{existing.__name__}.",
)
def builtin_tool(
tool_cls: TFunctionTool | None = None,
*,
config: dict[str, Any] | None = None,
) -> TFunctionTool | Callable[[TFunctionTool], TFunctionTool]:
def _register(cls: TFunctionTool) -> TFunctionTool:
tool_name = _resolve_builtin_tool_name(cls)
existing = _builtin_tool_classes_by_name.get(tool_name)
if existing is not None and existing is not cls:
raise ValueError(
f"Builtin tool name conflict detected: {tool_name} is already registered by "
f"{existing.__module__}.{existing.__name__}.",
)
_builtin_tool_classes_by_name[tool_name] = tool_cls
_builtin_tool_names_by_class[tool_cls] = tool_name
return tool_cls
_builtin_tool_classes_by_name[tool_name] = cls
_builtin_tool_names_by_class[cls] = tool_name
if config is not None:
_BUILTIN_TOOL_CONFIG_RULES[tool_name] = _build_rule_from_config_map(config)
return cls
if tool_cls is None:
return _register
return _register(tool_cls)
def ensure_builtin_tools_loaded() -> None:
@@ -74,9 +264,64 @@ def iter_builtin_tool_classes() -> tuple[type[FunctionTool], ...]:
return tuple(_builtin_tool_classes_by_name.values())
def get_builtin_tool_config_rule(name: str) -> BuiltinToolConfigRule | None:
ensure_builtin_tools_loaded()
return _BUILTIN_TOOL_CONFIG_RULES.get(name)
def get_builtin_tool_config_statuses(
tool_name: str,
config_entries: list[dict[str, Any]],
) -> list[dict[str, Any]]:
rule = get_builtin_tool_config_rule(tool_name)
if rule is None:
return []
statuses: list[dict[str, Any]] = []
for entry in config_entries:
config = entry.get("config")
if not isinstance(config, dict):
continue
conditions = rule.evaluate(config)
enabled = bool(conditions) and all(
bool(condition.get("matched")) for condition in conditions
)
statuses.append(
{
"conf_id": entry.get("conf_id"),
"conf_name": entry.get("conf_name"),
"enabled": enabled,
"matched_conditions": [
condition for condition in conditions if condition.get("matched")
],
"failed_conditions": [
condition
for condition in conditions
if not condition.get("matched")
],
}
)
return statuses
def get_builtin_tool_config_tags(
tool_name: str,
config_entries: list[dict[str, Any]],
) -> list[dict[str, Any]]:
return [
status
for status in get_builtin_tool_config_statuses(tool_name, config_entries)
if status["enabled"]
]
__all__ = [
"builtin_tool",
"ensure_builtin_tools_loaded",
"get_builtin_tool_config_rule",
"get_builtin_tool_config_statuses",
"get_builtin_tool_config_tags",
"get_builtin_tool_class",
"get_builtin_tool_name",
"iter_builtin_tool_classes",

View File

@@ -20,6 +20,22 @@ WEB_SEARCH_TOOL_NAMES = [
"web_search_bocha",
"web_search_brave",
]
_TAVILY_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
"provider_settings.websearch_provider": "tavily",
}
_BOCHA_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
"provider_settings.websearch_provider": "bocha",
}
_BRAVE_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
"provider_settings.websearch_provider": "brave",
}
_BAIDU_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
"provider_settings.websearch_provider": "baidu_ai_search",
}
@std_dataclass
@@ -276,7 +292,7 @@ async def _baidu_search(
]
@builtin_tool
@builtin_tool(config=_TAVILY_WEB_SEARCH_TOOL_CONFIG)
@pydantic_dataclass
class TavilyWebSearchTool(FunctionTool[AstrAgentContext]):
name: str = "web_search_tavily"
@@ -359,7 +375,7 @@ class TavilyWebSearchTool(FunctionTool[AstrAgentContext]):
return _search_result_payload(results)
@builtin_tool
@builtin_tool(config=_TAVILY_WEB_SEARCH_TOOL_CONFIG)
@pydantic_dataclass
class TavilyExtractWebPageTool(FunctionTool[AstrAgentContext]):
name: str = "tavily_extract_web_page"
@@ -406,7 +422,7 @@ class TavilyExtractWebPageTool(FunctionTool[AstrAgentContext]):
return ret or "Error: Tavily web searcher does not return any results."
@builtin_tool
@builtin_tool(config=_BOCHA_WEB_SEARCH_TOOL_CONFIG)
@pydantic_dataclass
class BochaWebSearchTool(FunctionTool[AstrAgentContext]):
name: str = "web_search_bocha"
@@ -470,7 +486,7 @@ class BochaWebSearchTool(FunctionTool[AstrAgentContext]):
return _search_result_payload(results)
@builtin_tool
@builtin_tool(config=_BRAVE_WEB_SEARCH_TOOL_CONFIG)
@pydantic_dataclass
class BraveWebSearchTool(FunctionTool[AstrAgentContext]):
name: str = "web_search_brave"
@@ -528,7 +544,7 @@ class BraveWebSearchTool(FunctionTool[AstrAgentContext]):
return _search_result_payload(results)
@builtin_tool
@builtin_tool(config=_BAIDU_WEB_SEARCH_TOOL_CONFIG)
@pydantic_dataclass
class BaiduWebSearchTool(FunctionTool[AstrAgentContext]):
name: str = "web_search_baidu"

View File

@@ -1,32 +1,33 @@
"""Astrbot统一路径获取
"""Centralized AstrBot path helpers.
项目路径:固定为源码所在路径
根目录路径:默认为当前工作目录,可通过环境变量 ASTRBOT_ROOT 指定
数据目录路径:固定为根目录下的 data 目录
配置文件路径:固定为数据目录下的 config 目录
插件目录路径:固定为数据目录下的 plugins 目录
插件数据目录路径:固定为数据目录下的 plugin_data 目录
T2I 模板目录路径:固定为数据目录下的 t2i_templates 目录
WebChat 数据目录路径:固定为数据目录下的 webchat 目录
临时文件目录路径:固定为数据目录下的 temp 目录
Skills 目录路径:固定为数据目录下的 skills 目录
第三方依赖目录路径:固定为数据目录下的 site-packages 目录
Project path:
- Fixed to the source tree location.
Root path:
- Defaults to the current working directory.
- Can be overridden with the ``ASTRBOT_ROOT`` environment variable.
Data subdirectories:
- Most runtime data lives under ``<root>/data``.
- A few tool-runtime files intentionally live under the system temporary
directory as ``.astrbot``.
"""
import os
import tempfile
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
def get_astrbot_path() -> str:
"""获取Astrbot项目路径"""
"""Return the AstrBot project source path."""
return os.path.realpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../../"),
)
def get_astrbot_root() -> str:
"""获取Astrbot根目录路径"""
"""Return the AstrBot root directory."""
if path := os.environ.get("ASTRBOT_ROOT"):
return os.path.realpath(path)
if is_packaged_desktop_runtime():
@@ -35,55 +36,65 @@ def get_astrbot_root() -> str:
def get_astrbot_data_path() -> str:
"""获取Astrbot数据目录路径"""
"""Return the AstrBot data directory path."""
return os.path.realpath(os.path.join(get_astrbot_root(), "data"))
def get_astrbot_config_path() -> str:
"""获取Astrbot配置文件路径"""
"""Return the AstrBot config directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "config"))
def get_astrbot_plugin_path() -> str:
"""获取Astrbot插件目录路径"""
"""Return the AstrBot plugin directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "plugins"))
def get_astrbot_plugin_data_path() -> str:
"""获取Astrbot插件数据目录路径"""
"""Return the AstrBot plugin data directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "plugin_data"))
def get_astrbot_t2i_templates_path() -> str:
"""获取Astrbot T2I 模板目录路径"""
"""Return the AstrBot T2I templates directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "t2i_templates"))
def get_astrbot_webchat_path() -> str:
"""获取Astrbot WebChat 数据目录路径"""
"""Return the AstrBot WebChat data directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "webchat"))
def get_astrbot_temp_path() -> str:
"""获取Astrbot临时文件目录路径"""
"""Return the AstrBot temporary data directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "temp"))
def get_astrbot_skills_path() -> str:
"""获取Astrbot Skills 目录路径"""
"""Return the AstrBot skills directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "skills"))
def get_astrbot_workspaces_path() -> str:
"""Return the AstrBot workspaces directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "workspaces"))
def get_astrbot_system_tmp_path() -> str:
"""Return the shared system temporary directory used by local tools."""
return os.path.realpath(os.path.join(tempfile.gettempdir(), ".astrbot"))
def get_astrbot_site_packages_path() -> str:
"""获取Astrbot第三方依赖目录路径"""
"""Return the AstrBot third-party site-packages directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "site-packages"))
def get_astrbot_knowledge_base_path() -> str:
"""获取Astrbot知识库根目录路径"""
"""Return the AstrBot knowledge base root path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "knowledge_base"))
def get_astrbot_backups_path() -> str:
"""获取Astrbot备份目录路径"""
"""Return the AstrBot backups directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "backups"))

View File

@@ -982,6 +982,7 @@ class PipInstaller:
package_name: str | None = None,
requirements_path: str | None = None,
mirror: str | None = None,
allow_target_upgrade: bool = True,
) -> None:
args, requested_requirements = self._build_pip_args(
package_name, requirements_path, mirror
@@ -995,15 +996,17 @@ class PipInstaller:
target_site_packages = get_astrbot_site_packages_path()
os.makedirs(target_site_packages, exist_ok=True)
_prepend_sys_path(target_site_packages)
args.extend(
[
"--target",
target_site_packages,
"--upgrade",
"--upgrade-strategy",
"only-if-needed",
]
)
# `allow_target_upgrade` only matters for packaged desktop installs that
# write into the shared `data/site-packages` target directory.
args.extend(["--target", target_site_packages])
if allow_target_upgrade:
args.extend(
[
"--upgrade",
"--upgrade-strategy",
"only-if-needed",
]
)
with self._core_constraints.constraints_file() as constraints_file_path:
if constraints_file_path:

View File

@@ -29,10 +29,17 @@ class ParsedPackageInput:
requirement_names: frozenset[str]
@dataclass(frozen=True)
class MissingRequirementsAnalysis:
missing_names: frozenset[str]
version_mismatch_names: frozenset[str] = frozenset()
@dataclass(frozen=True)
class MissingRequirementsPlan:
missing_names: frozenset[str]
install_lines: tuple[str, ...]
version_mismatch_names: frozenset[str] = frozenset()
fallback_reason: str | None = None
@@ -394,15 +401,26 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None:
def find_missing_requirements_from_lines(
requirement_lines: Sequence[str],
) -> set[str] | None:
analysis = classify_missing_requirements_from_lines(requirement_lines)
if analysis is None:
return None
return set(analysis.missing_names)
def classify_missing_requirements_from_lines(
requirement_lines: Sequence[str],
) -> MissingRequirementsAnalysis | None:
required = list(iter_requirements(lines=requirement_lines))
if not required:
return set()
return MissingRequirementsAnalysis(missing_names=frozenset())
installed = collect_installed_distribution_versions(get_requirement_check_paths())
if installed is None:
return None
missing: set[str] = set()
version_mismatch_names: set[str] = set()
for name, specifier in required:
installed_version = installed.get(name)
if not installed_version:
@@ -410,8 +428,12 @@ def find_missing_requirements_from_lines(
continue
if specifier and not _specifier_contains_version(specifier, installed_version):
missing.add(name)
version_mismatch_names.add(name)
return missing
return MissingRequirementsAnalysis(
missing_names=frozenset(missing),
version_mismatch_names=frozenset(version_mismatch_names),
)
def build_missing_requirements_install_lines(
@@ -449,9 +471,11 @@ def plan_missing_requirements_install(
if not can_precheck or requirement_lines is None:
return None
missing = find_missing_requirements_from_lines(requirement_lines)
if missing is None:
analysis = classify_missing_requirements_from_lines(requirement_lines)
if analysis is None:
return None
missing = analysis.missing_names
version_mismatch_names = analysis.version_mismatch_names
install_lines = build_missing_requirements_install_lines(
requirements_path,
@@ -468,12 +492,14 @@ def plan_missing_requirements_install(
)
return MissingRequirementsPlan(
missing_names=frozenset(missing),
version_mismatch_names=frozenset(version_mismatch_names),
install_lines=(),
fallback_reason="unmapped missing requirement names",
)
return MissingRequirementsPlan(
missing_names=frozenset(missing),
version_mismatch_names=frozenset(version_mismatch_names),
install_lines=install_lines,
)

View File

@@ -135,22 +135,127 @@ class CronRoute(Route):
if not isinstance(payload, dict):
return jsonify(Response().error("Invalid payload").__dict__)
updates = {
"name": payload.get("name"),
"cron_expression": payload.get("cron_expression"),
"description": payload.get("description"),
"enabled": payload.get("enabled"),
"timezone": payload.get("timezone"),
"run_once": payload.get("run_once"),
"payload": payload.get("payload"),
}
# remove None values to avoid unwanted resets
updates = {k: v for k, v in updates.items() if v is not None}
if "run_at" in payload:
updates.setdefault("payload", {})
if updates["payload"] is None:
updates["payload"] = {}
updates["payload"]["run_at"] = payload.get("run_at")
job = await cron_mgr.db.get_cron_job(job_id)
if not job:
return jsonify(Response().error("Job not found").__dict__)
updates = {}
if "name" in payload:
name = str(payload.get("name") or "").strip()
if not name:
return jsonify(Response().error("name cannot be empty").__dict__)
updates["name"] = name
if "enabled" in payload:
updates["enabled"] = bool(payload.get("enabled"))
if "timezone" in payload:
timezone = payload.get("timezone")
updates["timezone"] = str(timezone).strip() or None
next_run_once = (
bool(payload.get("run_once"))
if "run_once" in payload
else bool(job.run_once)
)
if job.job_type == "active_agent":
merged_payload = (
dict(job.payload) if isinstance(job.payload, dict) else {}
)
if "payload" in payload and isinstance(payload.get("payload"), dict):
merged_payload.update(payload["payload"])
if "session" in payload:
session = str(payload.get("session") or "").strip()
if not session:
return jsonify(
Response().error("session cannot be empty").__dict__
)
merged_payload["session"] = session
note_updated = False
if "note" in payload:
note = str(payload.get("note") or "").strip()
if not note:
return jsonify(
Response().error("note cannot be empty").__dict__
)
merged_payload["note"] = note
updates["description"] = note
note_updated = True
elif "description" in payload:
description = str(payload.get("description") or "").strip()
if not description:
return jsonify(
Response().error("description cannot be empty").__dict__
)
updates["description"] = description
merged_payload["note"] = description
note_updated = True
if not note_updated and updates.get("description") is None:
existing_note = str(
merged_payload.get("note") or job.description or ""
).strip()
if existing_note:
merged_payload["note"] = existing_note
next_cron_expression = (
payload.get("cron_expression")
if "cron_expression" in payload
else job.cron_expression
)
if next_cron_expression is not None:
next_cron_expression = str(next_cron_expression).strip() or None
run_at_raw = (
payload.get("run_at")
if "run_at" in payload
else merged_payload.get("run_at")
)
run_at_iso = None
if run_at_raw:
try:
run_at_iso = datetime.fromisoformat(str(run_at_raw)).isoformat()
except Exception:
return jsonify(
Response().error("run_at must be ISO datetime").__dict__
)
if next_run_once:
if not run_at_iso:
return jsonify(
Response()
.error("run_at is required when run_once=true")
.__dict__
)
next_cron_expression = None
merged_payload["run_at"] = run_at_iso
else:
if not next_cron_expression:
return jsonify(
Response()
.error("cron_expression is required when run_once=false")
.__dict__
)
merged_payload.pop("run_at", None)
updates["run_once"] = next_run_once
updates["cron_expression"] = next_cron_expression
updates["payload"] = merged_payload
else:
if "cron_expression" in payload:
cron_expression = str(payload.get("cron_expression") or "").strip()
if not cron_expression:
return jsonify(
Response().error("cron_expression cannot be empty").__dict__
)
updates["cron_expression"] = cron_expression
if "description" in payload:
description = str(payload.get("description") or "").strip()
updates["description"] = description or None
job = await cron_mgr.update_job(job_id, **updates)
if not job:

View File

@@ -3,9 +3,10 @@ import traceback
from quart import request
from astrbot.core import logger
from astrbot.core.agent.mcp_client import MCPTool
from astrbot.core.agent.mcp_client import MCPTool, validate_mcp_stdio_config
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.star import star_map
from astrbot.core.tools.registry import get_builtin_tool_config_statuses
from .route import Response, Route, RouteContext
@@ -152,6 +153,11 @@ class ToolsRoute(Route):
.__dict__
)
try:
validate_mcp_stdio_config(server_config)
except ValueError as e:
return Response().error(f"{e!s}").__dict__
config = self.tool_mgr.load_mcp_config()
if name in config["mcpServers"]:
@@ -255,6 +261,11 @@ class ToolsRoute(Route):
if key != "active": # 除了active之外的所有字段都保留
server_config[key] = value
try:
validate_mcp_stdio_config(server_config)
except ValueError as e:
return Response().error(f"{e!s}").__dict__
# config["mcpServers"][name] = server_config
if is_rename:
config["mcpServers"].pop(old_name)
@@ -414,6 +425,11 @@ class ToolsRoute(Route):
.__dict__
)
try:
validate_mcp_stdio_config(config)
except ValueError as e:
return Response().error(f"{e!s}").__dict__
tools_name = await self.tool_mgr.test_mcp_server_connection(config)
return (
Response()
@@ -434,13 +450,36 @@ class ToolsRoute(Route):
if tool.name not in existing_names:
tools.append(tool)
conf_list = self.core_lifecycle.astrbot_config_mgr.get_conf_list()
conf_name_map = {conf["id"]: conf["name"] for conf in conf_list}
config_entries = []
for conf_id, conf in self.core_lifecycle.astrbot_config_mgr.confs.items():
config_entries.append(
{
"conf_id": conf_id,
"conf_name": conf_name_map.get(conf_id, conf_id),
"config": conf,
}
)
tools_dict = []
for tool in tools:
readonly = False
builtin_config_statuses = []
builtin_config_tags = []
if self.tool_mgr.is_builtin_tool(tool.name):
origin = "builtin"
origin_name = "AstrBot Core"
readonly = True
builtin_config_statuses = get_builtin_tool_config_statuses(
tool.name,
config_entries,
)
builtin_config_tags = [
status
for status in builtin_config_statuses
if status["enabled"]
]
elif isinstance(tool, MCPTool):
origin = "mcp"
origin_name = tool.mcp_server_name
@@ -462,6 +501,8 @@ class ToolsRoute(Route):
"origin": origin,
"origin_name": origin_name,
"readonly": readonly,
"builtin_config_statuses": builtin_config_statuses,
"builtin_config_tags": builtin_config_tags,
}
tools_dict.append(tool_info)
return Response().ok(data=tools_dict).__dict__

View File

@@ -0,0 +1,83 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
### 新增
- 为电脑使用能力支持文件读取(read)、写入(write)、编辑(edit)、Grep 搜索(ripgrep)与按会话隔离的 workspace。[#7402](https://github.com/AstrBotDevs/AstrBot/pull/7402)
- 新增 Brave Search 网页搜索工具,替代旧的默认网页搜索实现。([#6847](https://github.com/AstrBotDevs/AstrBot/pull/6847)
- 新增 Mattermost 平台适配器支持。([#7369](https://github.com/AstrBotDevs/AstrBot/pull/7369)
- 新增 NVIDIA Rerank Provider。[#7227](https://github.com/AstrBotDevs/AstrBot/pull/7227)
- 新增 OpenAI、Gemini 音频输入模态支持,并修复 ChatUI 录音相关问题。([#7378](https://github.com/AstrBotDevs/AstrBot/pull/7378)
- Discord 平台新增 Bot 消息过滤配置,允许控制是否接收其他 Bot 的消息。([#6505](https://github.com/AstrBotDevs/AstrBot/pull/6505)
- 新增 LLM 对重复工具调用的引导能力,减少模型陷入重复调用工具的情况。([#7388](https://github.com/AstrBotDevs/AstrBot/pull/7388)
### 优化
- 合并 Cron 相关工具为统一管理工具,并新增 Cron 任务编辑能力。([#7445](https://github.com/AstrBotDevs/AstrBot/pull/7445)
- 重构内置工具管理逻辑,改善 AstrBot 内置工具注册与调用维护性。([#7418](https://github.com/AstrBotDevs/AstrBot/pull/7418)
- 移除默认网页搜索实现,改由新的搜索工具链路提供能力。([#7416](https://github.com/AstrBotDevs/AstrBot/pull/7416)
- 移除 `lxml``beautifulsoup4` 依赖,降低安装体积与依赖复杂度。([#7449](https://github.com/AstrBotDevs/AstrBot/pull/7449)
- 降低 MCP Server 状态轮询频率,减少后台请求开销。([#7399](https://github.com/AstrBotDevs/AstrBot/pull/7399)
- 修正文档中的路径拼接示例,避免插件开发存储文档误导使用者。([#7448](https://github.com/AstrBotDevs/AstrBot/pull/7448)
### 修复
- 修复 Windows 桌面端插件依赖加载不安全或失败的问题。([#7446](https://github.com/AstrBotDevs/AstrBot/pull/7446)
- 修复 Telegram 长消息最终分段过长的问题。([#7432](https://github.com/AstrBotDevs/AstrBot/pull/7432)
- 修复 Telegram `sendMessageDraft` 发送空文本导致 400 错误刷屏的问题。([#7398](https://github.com/AstrBotDevs/AstrBot/pull/7398)
- 修复 Telegram 收集命令时插件 handler 不在 `star_map` 中导致 `KeyError` 的问题。([#7405](https://github.com/AstrBotDevs/AstrBot/pull/7405)
- 修复 QQ 官方 WebSocket 关闭流程清理不完整的问题。([#7395](https://github.com/AstrBotDevs/AstrBot/pull/7395)
- 修复 Gemini FunctionResponse 中不支持的 `id` 字段导致请求失败的问题。([#7386](https://github.com/AstrBotDevs/AstrBot/pull/7386)
- 修复 Gemini 空模型输出误触发错误处理的问题。([#7377](https://github.com/AstrBotDevs/AstrBot/pull/7377)
- 修复仅存在原生工具时仍传递 `FunctionCallingConfig` 的问题。([#7407](https://github.com/AstrBotDevs/AstrBot/pull/7407)
- 修复 ChatUI 项目常量缺失,并补充相关测试用例。([#7414](https://github.com/AstrBotDevs/AstrBot/pull/7414)
- 修复 WebUI 暗色模式渲染与多处交互问题。([#7173](https://github.com/AstrBotDevs/AstrBot/pull/7173)
- 修复页面切换时浮动按钮跳动的问题。([#7214](https://github.com/AstrBotDevs/AstrBot/pull/7214)
- 修复 `faiss` 在启动阶段过早导入导致部分环境启动失败的问题。([#7400](https://github.com/AstrBotDevs/AstrBot/pull/7400)
- 修复微信个人号适配器缺少上下文 token 时的警告信息。([commit](https://github.com/AstrBotDevs/AstrBot/commit/dfca5cdb)
- 修复工具结果断言与动态阈值不一致的问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/8c6c00ae)
<a id="english"></a>
## What's Changed (EN)
### New Features
- Added local Computer Use filesystem tools, including file read, write, edit, Grep search, and per-session workspace support. ([#7402](https://github.com/AstrBotDevs/AstrBot/pull/7402))
- Added the Brave Search web search tool, replacing the old default web search implementation. ([#6847](https://github.com/AstrBotDevs/AstrBot/pull/6847))
- Added Mattermost platform support. ([#7369](https://github.com/AstrBotDevs/AstrBot/pull/7369))
- Added the NVIDIA Rerank Provider. ([#7227](https://github.com/AstrBotDevs/AstrBot/pull/7227))
- Added audio input support across providers and fixed ChatUI recording issues. ([#7378](https://github.com/AstrBotDevs/AstrBot/pull/7378))
- Added configurable Discord bot-message filtering, including support for receiving messages from other bots. ([#6505](https://github.com/AstrBotDevs/AstrBot/pull/6505))
- Added LLM guidance for repeated tool calls to reduce repetitive tool-call loops. ([#7388](https://github.com/AstrBotDevs/AstrBot/pull/7388))
### Improvements
- Merged Cron tools into a unified management tool and added Cron task editing. ([#7445](https://github.com/AstrBotDevs/AstrBot/pull/7445))
- Refactored built-in tool management to improve registration and maintenance. ([#7418](https://github.com/AstrBotDevs/AstrBot/pull/7418))
- Removed the old default web search implementation in favor of the new search tool flow. ([#7416](https://github.com/AstrBotDevs/AstrBot/pull/7416))
- Removed `lxml` and `beautifulsoup4` dependencies to reduce dependency weight and installation complexity. ([#7449](https://github.com/AstrBotDevs/AstrBot/pull/7449))
- Reduced MCP Server status polling frequency to lower background request overhead. ([#7399](https://github.com/AstrBotDevs/AstrBot/pull/7399))
- Fixed a path concatenation example in the plugin storage docs. ([#7448](https://github.com/AstrBotDevs/AstrBot/pull/7448))
- Updated the README logo. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/e34d9504))
### Bug Fixes
- Fixed safer desktop plugin dependency loading on Windows. ([#7446](https://github.com/AstrBotDevs/AstrBot/pull/7446))
- Fixed overly long final Telegram message segments. ([#7432](https://github.com/AstrBotDevs/AstrBot/pull/7432))
- Fixed Telegram `sendMessageDraft` 400 spam caused by empty text. ([#7398](https://github.com/AstrBotDevs/AstrBot/pull/7398))
- Fixed a `KeyError` in Telegram command collection when a plugin handler is missing from `star_map`. ([#7405](https://github.com/AstrBotDevs/AstrBot/pull/7405))
- Cleaned up QQ Official WebSocket shutdown handling. ([#7395](https://github.com/AstrBotDevs/AstrBot/pull/7395))
- Removed the unsupported `id` field from Gemini FunctionResponse payloads. ([#7386](https://github.com/AstrBotDevs/AstrBot/pull/7386))
- Fixed empty model output handling that could misfire when using Gemini. ([#7377](https://github.com/AstrBotDevs/AstrBot/pull/7377))
- Skipped `FunctionCallingConfig` when only native tools are present. ([#7407](https://github.com/AstrBotDevs/AstrBot/pull/7407))
- Added missing ChatUI project constants and related tests. ([#7414](https://github.com/AstrBotDevs/AstrBot/pull/7414))
- Fixed WebUI dark-mode rendering and multiple interaction bugs. ([#7173](https://github.com/AstrBotDevs/AstrBot/pull/7173))
- Prevented floating buttons from jumping during page transitions. ([#7214](https://github.com/AstrBotDevs/AstrBot/pull/7214))
- Deferred `faiss` imports during startup to avoid startup failures in some environments. ([#7400](https://github.com/AstrBotDevs/AstrBot/pull/7400))
- Improved the missing-context-token warning message in the Weixin OC adapter. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/dfca5cdb))
- Updated tool-result assertions to match dynamic threshold values. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/8c6c00ae))

125
changelogs/v4.23.0.md Normal file
View File

@@ -0,0 +1,125 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
### 新增
- 为电脑使用能力支持文件读取(read)、写入(write)、编辑(edit)、Grep 搜索(ripgrep)与按会话隔离的 workspace。[#7402](https://github.com/AstrBotDevs/AstrBot/pull/7402)
- 微信个人号适配器支持引用消息的解析。([#7380](https://github.com/AstrBotDevs/AstrBot/pull/7380)
- 新增 Brave Search 网页搜索工具,移除旧的默认网页搜索实现。([#6847](https://github.com/AstrBotDevs/AstrBot/pull/6847)
- 新增 Mattermost 平台适配器支持。([#7369](https://github.com/AstrBotDevs/AstrBot/pull/7369)
- 新增 NVIDIA Rerank Provider。[#7227](https://github.com/AstrBotDevs/AstrBot/pull/7227)
- 新增 LongCat LLM Provider。[#7360](https://github.com/AstrBotDevs/AstrBot/pull/7360)
- 新增 OpenAI、Gemini 音频输入模态支持,并修复 ChatUI 录音相关问题。([#7378](https://github.com/AstrBotDevs/AstrBot/pull/7378)
- Discord 平台新增 Bot 消息过滤配置,允许控制是否接收其他 Bot 的消息。([#6505](https://github.com/AstrBotDevs/AstrBot/pull/6505)
- 新增 LLM 对重复工具调用的引导能力,减少模型陷入重复调用工具的情况。([#7388](https://github.com/AstrBotDevs/AstrBot/pull/7388)
- QQ 官方 API 文件上传新增重试机制,提升文件发送稳定性。([#7430](https://github.com/AstrBotDevs/AstrBot/pull/7430)
### 优化
- 重构 ChatUI 样式、消息渲染与输入体验,改善聊天界面的整体可维护性与交互一致性。([#7485](https://github.com/AstrBotDevs/AstrBot/pull/7485)
- 合并 Cron 相关工具为统一管理工具,并新增 Cron 任务编辑能力。([#7445](https://github.com/AstrBotDevs/AstrBot/pull/7445)
- 重构内置工具管理逻辑,改善 AstrBot 内置工具注册与调用维护性。([#7418](https://github.com/AstrBotDevs/AstrBot/pull/7418)
- 移除了大部分低频内置命令并整合命令功能,将这些指令挪至 [builtin-command-extension](https://github.com/AstrBotDevs/builtin_commands_extension) 插件,同时更新文档。([#7478](https://github.com/AstrBotDevs/AstrBot/pull/7478)
- 移除默认网页搜索实现,改由新的搜索工具链路提供能力。([#7416](https://github.com/AstrBotDevs/AstrBot/pull/7416)
- 移除 `lxml``beautifulsoup4` 依赖,降低安装体积与依赖复杂度。([#7449](https://github.com/AstrBotDevs/AstrBot/pull/7449)
- 新增 MCP stdio 配置校验,降低无效配置导致的启动失败与排查成本。([#7477](https://github.com/AstrBotDevs/AstrBot/pull/7477)
- Docker 运行配置启用 `no-new-privileges`,提升容器默认安全性。([commit](https://github.com/AstrBotDevs/AstrBot/commit/68a195e12)
- 降低 MCP Server 状态轮询频率,减少后台请求开销。([#7399](https://github.com/AstrBotDevs/AstrBot/pull/7399)
- 帮助命令忽略 `dashboard_update` 等内部有效命令,减少帮助列表噪音。([commit](https://github.com/AstrBotDevs/AstrBot/commit/baaad2a69)
- 修正文档中的路径拼接示例,避免插件开发存储文档误导使用者。([#7448](https://github.com/AstrBotDevs/AstrBot/pull/7448)
- 补充 Matrix 平台常量、平台图标与相关文档。([#7368](https://github.com/AstrBotDevs/AstrBot/pull/7368)
### 修复
- 修复 STT 或 TTS Provider 在配置中禁用时仍可能被取用的问题。([#7363](https://github.com/AstrBotDevs/AstrBot/pull/7363)
- 修复 Gemini 空模型输出误触发错误处理的问题。([#7377](https://github.com/AstrBotDevs/AstrBot/pull/7377)
- 修复 Gemini FunctionResponse 中不支持的 `id` 字段导致请求失败的问题。([#7386](https://github.com/AstrBotDevs/AstrBot/pull/7386)
- 修复仅存在原生工具时仍传递 `FunctionCallingConfig` 的问题。([#7407](https://github.com/AstrBotDevs/AstrBot/pull/7407)
- 修复工具结果断言与动态阈值不一致的问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/8c6c00ae6)
- 修复 Bailian Rerank 对不同 URL 端点返回格式的兼容性问题。([#7250](https://github.com/AstrBotDevs/AstrBot/pull/7250)
- 修复 QQ 官方 WebSocket 关闭流程清理不完整的问题。([#7395](https://github.com/AstrBotDevs/AstrBot/pull/7395)
- 修复 Telegram 网络异常后的轮询恢复问题,并补充相关测试。([#7468](https://github.com/AstrBotDevs/AstrBot/pull/7468)
- 修复 Telegram 长消息最终分段过长的问题。([#7432](https://github.com/AstrBotDevs/AstrBot/pull/7432)
- 修复 Telegram `sendMessageDraft` 发送空文本导致 400 错误刷屏的问题。([#7398](https://github.com/AstrBotDevs/AstrBot/pull/7398)
- 修复 Telegram 收集命令时插件 handler 不在 `star_map` 中导致 `KeyError` 的问题。([#7405](https://github.com/AstrBotDevs/AstrBot/pull/7405)
- 修复 Discord Slash Command 未及时 defer 导致 `10062 Unknown interaction` 的问题。([#7474](https://github.com/AstrBotDevs/AstrBot/pull/7474)
- 修复微信个人号适配器缺少上下文 token 时的警告信息。([commit](https://github.com/AstrBotDevs/AstrBot/commit/dfca5cdb7)
- 修复 `group_icl_enable` 在消息处理时未使用 UMO 绑定配置的问题。([#7397](https://github.com/AstrBotDevs/AstrBot/pull/7397)
- 修复插件函数工具模块路径与插件主模块路径不一致的问题。([#7462](https://github.com/AstrBotDevs/AstrBot/pull/7462)
- 修复插件版本检查逻辑对预发布版本的支持问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/5f95bbc42)
- 修复 Shell 命令执行 JSON 响应中非 ASCII 字符被转义的问题。([#7475](https://github.com/AstrBotDevs/AstrBot/pull/7475)
- 修复 Windows 桌面端插件依赖加载不安全或失败的问题。([#7446](https://github.com/AstrBotDevs/AstrBot/pull/7446)
- 修复 `faiss` 在启动阶段过早导入导致部分环境启动失败的问题。([#7400](https://github.com/AstrBotDevs/AstrBot/pull/7400)
- 修复 WebUI 暗色模式渲染与多处交互问题。([#7173](https://github.com/AstrBotDevs/AstrBot/pull/7173)
- 修复 ChatUI 项目常量缺失,并补充相关测试用例。([#7414](https://github.com/AstrBotDevs/AstrBot/pull/7414)
- 修复页面切换时浮动按钮跳动的问题。([#7214](https://github.com/AstrBotDevs/AstrBot/pull/7214)
- 修复对话管理页依赖的 `MessageList.vue` 缺失导致 Linux 环境 Dashboard 构建失败的问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/717228143)
- 修复工具调用图标使用了 MDI 子集缺失图标导致的显示问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/d1913b595)
- 修复 Dashboard store 中 `defineStore` 的类型用法问题。([#7490](https://github.com/AstrBotDevs/AstrBot/pull/7490)
- 修复 compose 文件中的 Docker 镜像名称错误。([#7488](https://github.com/AstrBotDevs/AstrBot/pull/7488)
<a id="english"></a>
## What's Changed (EN)
### New Features
- Added the LongCat LLM Provider. ([#7360](https://github.com/AstrBotDevs/AstrBot/pull/7360))
- Added missing Matrix platform constants, platform logo, and documentation updates. ([#7368](https://github.com/AstrBotDevs/AstrBot/pull/7368))
- Added local Computer Use filesystem tools, including file read, write, edit, Grep search, and per-session workspace support. ([#7402](https://github.com/AstrBotDevs/AstrBot/pull/7402))
- Added the Brave Search web search tool, replacing the old default web search implementation. ([#6847](https://github.com/AstrBotDevs/AstrBot/pull/6847))
- Added Mattermost platform support. ([#7369](https://github.com/AstrBotDevs/AstrBot/pull/7369))
- Added the NVIDIA Rerank Provider. ([#7227](https://github.com/AstrBotDevs/AstrBot/pull/7227))
- Added audio input support across OpenAI and Gemini providers, and fixed ChatUI recording issues. ([#7378](https://github.com/AstrBotDevs/AstrBot/pull/7378))
- Added reply component support for the Weixin OC adapter. ([#7380](https://github.com/AstrBotDevs/AstrBot/pull/7380))
- Added configurable Discord bot-message filtering, including support for receiving messages from other bots. ([#6505](https://github.com/AstrBotDevs/AstrBot/pull/6505))
- Added LLM guidance for repeated tool calls to reduce repetitive tool-call loops. ([#7388](https://github.com/AstrBotDevs/AstrBot/pull/7388))
- Added retry handling for QQ Official API file uploads to improve file-send reliability. ([#7430](https://github.com/AstrBotDevs/AstrBot/pull/7430))
### Improvements
- Refactored ChatUI styling, message rendering, and input interactions for better maintainability and UI consistency. ([#7485](https://github.com/AstrBotDevs/AstrBot/pull/7485))
- Merged Cron tools into a unified management tool and added Cron task editing. ([#7445](https://github.com/AstrBotDevs/AstrBot/pull/7445))
- Refactored built-in tool management to improve registration and maintenance. ([#7418](https://github.com/AstrBotDevs/AstrBot/pull/7418))
- Removed rarely used built-in commands, consolidated command functionality, and updated command documentation. ([#7478](https://github.com/AstrBotDevs/AstrBot/pull/7478))
- Removed the old default web search implementation in favor of the new search tool flow. ([#7416](https://github.com/AstrBotDevs/AstrBot/pull/7416))
- Removed `lxml` and `beautifulsoup4` dependencies to reduce dependency weight and installation complexity. ([#7449](https://github.com/AstrBotDevs/AstrBot/pull/7449))
- Added MCP stdio configuration validation to reduce startup failures caused by invalid configs. ([#7477](https://github.com/AstrBotDevs/AstrBot/pull/7477))
- Enabled `no-new-privileges` in Docker runtime configuration to improve default container security. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/68a195e12))
- Reduced MCP Server status polling frequency to lower background request overhead. ([#7399](https://github.com/AstrBotDevs/AstrBot/pull/7399))
- Ignored internal effective commands such as `dashboard_update` in HelpCommand to reduce help-list noise. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/baaad2a69))
- Fixed a path concatenation example in the plugin storage docs. ([#7448](https://github.com/AstrBotDevs/AstrBot/pull/7448))
- Updated the README logo. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/e34d9504e))
### Bug Fixes
- Returned `None` when STT or TTS providers are disabled in config instead of still resolving them. ([#7363](https://github.com/AstrBotDevs/AstrBot/pull/7363))
- Fixed empty model output handling that could misfire when using Gemini. ([#7377](https://github.com/AstrBotDevs/AstrBot/pull/7377))
- Removed the unsupported `id` field from Gemini FunctionResponse payloads. ([#7386](https://github.com/AstrBotDevs/AstrBot/pull/7386))
- Skipped `FunctionCallingConfig` when only native tools are present. ([#7407](https://github.com/AstrBotDevs/AstrBot/pull/7407))
- Updated tool-result assertions to match dynamic threshold values. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/8c6c00ae6))
- Fixed Bailian Rerank compatibility with different response formats based on URL endpoint. ([#7250](https://github.com/AstrBotDevs/AstrBot/pull/7250))
- Cleaned up QQ Official WebSocket shutdown handling. ([#7395](https://github.com/AstrBotDevs/AstrBot/pull/7395))
- Fixed Telegram polling recovery after network failures and added related tests. ([#7468](https://github.com/AstrBotDevs/AstrBot/pull/7468))
- Fixed overly long final Telegram message segments. ([#7432](https://github.com/AstrBotDevs/AstrBot/pull/7432))
- Fixed Telegram `sendMessageDraft` 400 spam caused by empty text. ([#7398](https://github.com/AstrBotDevs/AstrBot/pull/7398))
- Fixed a `KeyError` in Telegram command collection when a plugin handler is missing from `star_map`. ([#7405](https://github.com/AstrBotDevs/AstrBot/pull/7405))
- Fixed Discord `10062 Unknown interaction` errors by deferring slash commands immediately. ([#7474](https://github.com/AstrBotDevs/AstrBot/pull/7474))
- Improved the missing-context-token warning message in the Weixin OC adapter. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/dfca5cdb7))
- Fixed `group_icl_enable` to use UMO-bound config during message handling. ([#7397](https://github.com/AstrBotDevs/AstrBot/pull/7397))
- Aligned function tool module paths with plugin main module paths. ([#7462](https://github.com/AstrBotDevs/AstrBot/pull/7462))
- Fixed plugin version checks for pre-release versions. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/5f95bbc42))
- Preserved non-ASCII characters in JSON responses from shell command execution. ([#7475](https://github.com/AstrBotDevs/AstrBot/pull/7475))
- Fixed safer desktop plugin dependency loading on Windows. ([#7446](https://github.com/AstrBotDevs/AstrBot/pull/7446))
- Deferred `faiss` imports during startup to avoid startup failures in some environments. ([#7400](https://github.com/AstrBotDevs/AstrBot/pull/7400))
- Fixed WebUI dark-mode rendering and multiple interaction bugs. ([#7173](https://github.com/AstrBotDevs/AstrBot/pull/7173))
- Added missing ChatUI project constants and related tests. ([#7414](https://github.com/AstrBotDevs/AstrBot/pull/7414))
- Prevented floating buttons from jumping during page transitions. ([#7214](https://github.com/AstrBotDevs/AstrBot/pull/7214))
- Restored `MessageList.vue` for the conversation management page to fix Dashboard production builds on Linux. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/717228143))
- Updated tool-call icons to use an icon included in the MDI subset. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/d1913b595))
- Fixed `defineStore` type usage in Dashboard stores. ([#7490](https://github.com/AstrBotDevs/AstrBot/pull/7490))
- Fixed the Docker image name in the compose file. ([#7488](https://github.com/AstrBotDevs/AstrBot/pull/7488))

View File

@@ -4,10 +4,7 @@ version: '3.8'
services:
astrbot:
build:
context: .
dockerfile: Dockerfile
image: astrbot:kimi-code
image: soulter/astrbot:latest
container_name: astrbot
restart: always
ports: # mappings description: https://github.com/AstrBotDevs/AstrBot/issues/497

View File

@@ -1,15 +1,17 @@
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
version: '3.8'
# When connecting to OneBot v11 Napcat, please use this compose file for one-click deployment: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
services:
astrbot:
image: soulter/astrbot:latest
container_name: astrbot
restart: always
ports: # mappings description: https://github.com/AstrBotDevs/AstrBot/issues/497
- "6185:6185" # 必选AstrBot WebUI 端口
- "6199:6199" # 可选, QQ 个人号 WebSocket 端口
# - "6195:6195" # 可选, 企业微信 Webhook 端口
# - "6196:6196" # 可选, QQ 官方接口 Webhook 端口
security_opt:
- no-new-privileges:true
ports:
- "6185:6185" # AstrBot WebUI
- "6199:6199" # Optional. OneBot v11 Napcat Websocket Port
environment:
- TZ=Asia/Shanghai
volumes:

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 256 icons */
/* Auto-generated MDI subset 248 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -180,10 +180,6 @@
content: "\F0132";
}
.mdi-checkbox-multiple-marked-outline::before {
content: "\F0139";
}
.mdi-chevron-double-left::before {
content: "\F013D";
}
@@ -240,6 +236,10 @@
content: "\F0167";
}
.mdi-code-braces::before {
content: "\F0169";
}
.mdi-code-json::before {
content: "\F0626";
}
@@ -440,10 +440,6 @@
content: "\F0A4D";
}
.mdi-file-upload-outline::before {
content: "\F0A4E";
}
.mdi-file-word-box::before {
content: "\F022D";
}
@@ -456,14 +452,6 @@
content: "\F0236";
}
.mdi-flash::before {
content: "\F0241";
}
.mdi-flash-off::before {
content: "\F0243";
}
.mdi-folder::before {
content: "\F024B";
}
@@ -576,18 +564,10 @@
content: "\F0309";
}
.mdi-keyboard-outline::before {
content: "\F097B";
}
.mdi-label::before {
content: "\F0315";
}
.mdi-lan-connect::before {
content: "\F0318";
}
.mdi-language-markdown::before {
content: "\F0354";
}
@@ -652,10 +632,6 @@
content: "\F035F";
}
.mdi-message-off-outline::before {
content: "\F164E";
}
.mdi-message-outline::before {
content: "\F0365";
}
@@ -664,10 +640,6 @@
content: "\F0369";
}
.mdi-message-text-outline::before {
content: "\F036A";
}
.mdi-microphone::before {
content: "\F036C";
}
@@ -1036,10 +1008,6 @@
content: "\F05B7";
}
.mdi-wrench-outline::before {
content: "\F0BE0";
}
.mdi-zip-box::before {
content: "\F05C4";
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,696 +0,0 @@
<template>
<div class="sidebar-panel"
:class="{
'sidebar-collapsed': sidebarCollapsed && !isMobile,
'mobile-sidebar-open': isMobile && mobileMenuOpen,
'mobile-sidebar': isMobile
}"
:style="{ backgroundColor: sidebarCollapsed && !isMobile ? 'rgb(var(--v-theme-surface))' : 'rgb(var(--v-theme-mcpCardBg))' }">
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
<v-icon>{{ sidebarCollapsed ? 'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
</v-btn>
</div>
<div class="sidebar-collapse-btn-container" v-if="isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="$emit('closeMobileSidebar')" variant="text"
color="deep-purple">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div style="padding: 8px; opacity: 0.6;">
<div class="new-chat-row" v-if="!sidebarCollapsed || isMobile">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
<v-btn v-if="sessions.length > 0" icon size="small" variant="text" @click="toggleBatchMode"
:color="batchMode ? 'primary' : undefined">
<v-icon>mdi-checkbox-multiple-marked-outline</v-icon>
</v-btn>
</div>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div>
<!-- Batch action bar -->
<div v-if="batchMode && (!sidebarCollapsed || isMobile)" class="batch-action-bar">
<v-btn size="x-small" variant="text" @click="toggleSelectAll">
{{ isAllSelected ? tm('batch.deselectAll') : tm('batch.selectAll') }}
</v-btn>
<span class="batch-selected-count">{{ tm('batch.selected', { count: batchSelected.length }) }}</span>
<v-spacer />
<v-btn size="x-small" variant="text" color="error" :disabled="batchSelected.length === 0"
@click="handleBatchDelete">
{{ tm('batch.delete') }}
</v-btn>
</div>
<!-- 项目列表组件 -->
<ProjectList
v-if="!sidebarCollapsed || isMobile"
:projects="projects"
@selectProject="$emit('selectProject', $event)"
@createProject="$emit('createProject')"
@editProject="$emit('editProject', $event)"
@deleteProject="$emit('deleteProject', $event)"
/>
<div style="overflow-y: auto; flex-grow: 1; overscroll-behavior-y: contain;"
v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list"
style="background-color: transparent;" :selected="batchMode ? [] : selectedSessions"
@update:selected="handleListSelect">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary"
@click="batchMode ? toggleBatchItem(item.session_id) : undefined">
<template v-slot:prepend>
<div class="batch-checkbox-slot" :class="{ 'batch-checkbox-slot--active': batchMode }">
<v-checkbox-btn
:model-value="batchSelected.includes(item.session_id)"
@update:model-value="toggleBatchItem(item.session_id)"
@click.stop
density="compact"
hide-details
class="batch-checkbox"
/>
</div>
</template>
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
:style="{ color: 'rgb(var(--v-theme-primaryText))' }">
{{ item.display_name || tm('conversation.newConversation') }}
</v-list-item-title>
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
{{ new Date(item.updated_at).toLocaleString() }}
</v-list-item-subtitle> -->
<template v-if="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@click.stop="$emit('editTitle', item.session_id, item.display_name ?? '')" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-conversation-btn" color="error"
@click.stop="handleDeleteConversation(item)" />
</div>
</template>
</v-list-item>
</v-list>
</v-card>
<v-fade-transition>
<div class="no-conversations" v-if="sessions.length === 0">
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="no-conversations-text" v-if="!sidebarCollapsed || isMobile">
{{ tm('conversation.noHistory') }}
</div>
</div>
</v-fade-transition>
</div>
<!-- 收起时的占位元素 -->
<div class="sidebar-spacer" v-if="sidebarCollapsed && !isMobile"></div>
<!-- 底部设置按钮 -->
<div class="sidebar-footer">
<StyledMenu location="top" :close-on-content-click="false">
<template v-slot:activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
:icon="sidebarCollapsed && !isMobile"
:block="!sidebarCollapsed || isMobile"
variant="text"
class="settings-btn"
:class="{ 'settings-btn-collapsed': sidebarCollapsed && !isMobile }"
:prepend-icon="(!sidebarCollapsed || isMobile) ? 'mdi-cog-outline' : undefined"
>
<v-icon v-if="sidebarCollapsed && !isMobile">mdi-cog-outline</v-icon>
<template v-if="!sidebarCollapsed || isMobile">{{ t('core.common.settings') }}</template>
</v-btn>
</template>
<!-- 语言切换分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: languageMenuProps }">
<v-list-item
v-bind="languageMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
</template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current">{{ currentLanguage?.flag }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 主题切换 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
<template v-slot:prepend>
<v-icon>{{ isDark ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}</v-icon>
</template>
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
</v-list-item>
<!-- 通信传输模式分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: transportMenuProps }">
<v-list-item
v-bind="transportMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-lan-connect</v-icon>
</template>
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentTransportLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="opt in transportOptions"
:key="opt.value"
:value="opt.value"
@click="handleTransportModeChange(opt.value)"
:class="{ 'styled-menu-item-active': transportMode === opt.value }"
class="styled-menu-item"
rounded="md"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 发送快捷键分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: sendShortcutMenuProps }">
<v-list-item
v-bind="sendShortcutMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-keyboard-outline</v-icon>
</template>
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="opt in sendShortcutOptions"
:key="opt.value"
:value="opt.value"
@click="handleSendShortcutChange(opt.value)"
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
class="styled-menu-item"
rounded="md"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 全屏/退出全屏 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
<template v-slot:prepend>
<v-icon>{{ chatboxMode ? 'mdi-fullscreen-exit' : 'mdi-fullscreen' }}</v-icon>
</template>
<v-list-item-title>{{ chatboxMode ? tm('actions.exitFullscreen') : tm('actions.fullscreen') }}</v-list-item-title>
</v-list-item>
<!-- 提供商配置 -->
<v-list-item class="styled-menu-item" @click="showProviderConfigDialog = true">
<template v-slot:prepend>
<v-icon>mdi-creation</v-icon>
</template>
<v-list-item-title>{{ tm('actions.providerConfig') }}</v-list-item-title>
</v-list-item>
</StyledMenu>
</div>
<!-- 提供商配置对话框 -->
<ProviderConfigDialog v-model="showProviderConfigDialog" />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { Session } from '@/composables/useSessions';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
import StyledMenu from '@/components/shared/StyledMenu.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
import ProjectList from '@/components/chat/ProjectList.vue';
import type { Project } from '@/components/chat/ProjectList.vue';
import { useLanguageSwitcher } from '@/i18n/composables';
import type { Locale } from '@/i18n/types';
interface Props {
sessions: Session[];
selectedSessions: string[];
currSessionId: string;
selectedProjectId?: string | null;
transportMode: 'sse' | 'websocket';
isDark: boolean;
chatboxMode: boolean;
isMobile: boolean;
mobileMenuOpen: boolean;
projects?: Project[];
sendShortcut: 'enter' | 'shift_enter';
}
const props = withDefaults(defineProps<Props>(), {
projects: () => []
});
const emit = defineEmits<{
newChat: [];
selectConversation: [sessionIds: string[]];
editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string];
batchDeleteConversations: [sessionIds: string[]];
closeMobileSidebar: [];
toggleTheme: [];
toggleFullscreen: [];
selectProject: [projectId: string];
createProject: [];
editProject: [project: Project];
deleteProject: [projectId: string];
updateTransportMode: [mode: 'sse' | 'websocket'];
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
}>();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const confirmDialog = useConfirmDialog();
const sidebarCollapsed = ref(true);
const showProviderConfigDialog = ref(false);
// Batch mode state
const batchMode = ref(false);
const batchSelected = ref<string[]>([]);
const isAllSelected = computed(() =>
props.sessions.length > 0 && batchSelected.value.length === props.sessions.length
);
function toggleBatchMode() {
batchMode.value = !batchMode.value;
batchSelected.value = [];
}
function toggleBatchItem(sessionId: string) {
const idx = batchSelected.value.indexOf(sessionId);
if (idx >= 0) {
batchSelected.value.splice(idx, 1);
} else {
batchSelected.value.push(sessionId);
}
}
function toggleSelectAll() {
if (isAllSelected.value) {
batchSelected.value = [];
} else {
batchSelected.value = props.sessions.map(s => s.session_id);
}
}
async function handleBatchDelete() {
const count = batchSelected.value.length;
if (count === 0) return;
const message = tm('batch.confirmDelete', { count });
if (await askForConfirmation(message, confirmDialog)) {
emit('batchDeleteConversations', [...batchSelected.value]);
batchSelected.value = [];
batchMode.value = false;
}
}
function handleListSelect(sessionIds: string[]) {
if (!batchMode.value) {
emit('selectConversation', sessionIds);
}
}
const transportOptions = [
{ label: tm('transport.sse'), value: 'sse' as const },
{ label: tm('transport.websocket'), value: 'websocket' as const }
];
const sendShortcutOptions = [
{ label: tm('shortcuts.sendKey.enterToSend'), value: 'enter' as const },
{ label: tm('shortcuts.sendKey.shiftEnterToSend'), value: 'shift_enter' as const }
];
// Language switcher
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
const languages = computed(() =>
languageOptions.value.map(lang => ({
code: lang.value,
name: lang.label,
flag: lang.flag
}))
);
const currentLocale = computed(() => locale.value);
const changeLanguage = async (langCode: string) => {
await switchLanguage(langCode as Locale);
};
const currentTransportLabel = computed(() => {
const found = transportOptions.find(opt => opt.value === props.transportMode);
return found?.label ?? '';
});
const currentSendShortcutLabel = computed(() => {
const found = sendShortcutOptions.find(opt => opt.value === props.sendShortcut);
return found?.label ?? '';
});
// 从 localStorage 读取侧边栏折叠状态
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
if (savedCollapsedState !== null) {
sidebarCollapsed.value = JSON.parse(savedCollapsedState);
} else {
sidebarCollapsed.value = true;
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value;
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value));
}
async function handleDeleteConversation(session: Session) {
const sessionTitle = session.display_name || tm('conversation.newConversation');
const message = tm('conversation.confirmDelete', { name: sessionTitle });
if (await askForConfirmation(message, confirmDialog)) {
emit('deleteConversation', session.session_id);
}
}
function handleTransportModeChange(mode: string | null) {
if (mode === 'sse' || mode === 'websocket') {
emit('updateTransportMode', mode);
}
}
function handleSendShortcutChange(mode: string | null) {
if (mode === 'enter' || mode === 'shift_enter') {
emit('updateSendShortcut', mode);
}
}
</script>
<style scoped>
.sidebar-panel {
max-width: 270px;
min-width: 240px;
display: flex;
flex-direction: column;
padding: 0;
height: 100%;
max-height: 100%;
position: relative;
transition: all 0.3s ease;
overflow: hidden;
}
.sidebar-collapsed {
max-width: 60px;
min-width: 60px;
transition: all 0.3s ease;
}
.mobile-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
max-width: 280px !important;
min-width: 280px !important;
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 1000;
}
.mobile-sidebar-open {
transform: translateX(0) !important;
}
.sidebar-collapse-btn-container {
margin: 8px;
margin-bottom: 0px;
z-index: 10;
}
.sidebar-collapse-btn {
opacity: 0.6;
max-height: none;
overflow-y: visible;
padding: 0;
}
.new-chat-btn {
justify-content: flex-start;
background-color: transparent !important;
border-radius: 20px;
padding: 8px 16px !important;
}
.conversation-item {
/* margin-bottom: 4px; */
border-radius: 20px !important;
height: auto !important;
/* min-height: 56px; */
padding: 0px 16px !important;
position: relative;
}
.conversation-item:hover {
background-color: rgba(var(--v-theme-primary), 0.05);
}
.conversation-item:hover .conversation-actions {
opacity: 1;
visibility: visible;
}
.conversation-actions {
display: flex;
gap: 4px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
@media (max-width: 768px) {
.conversation-actions {
opacity: 1 !important;
visibility: visible !important;
}
}
.edit-title-btn,
.delete-conversation-btn {
opacity: 0.7;
transition: opacity 0.2s ease;
}
.edit-title-btn:hover,
.delete-conversation-btn:hover {
opacity: 1;
}
.conversation-title {
font-weight: 500;
font-size: 14px;
line-height: 1.3;
margin-bottom: 2px;
transition: opacity 0.25s ease;
}
.timestamp {
font-size: 11px;
color: var(--v-theme-secondaryText);
line-height: 1;
transition: opacity 0.25s ease;
}
.no-conversations {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 150px;
opacity: 0.6;
gap: 12px;
}
.no-conversations-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
transition: opacity 0.25s ease;
}
.sidebar-spacer {
flex-grow: 1;
}
.sidebar-footer {
padding: 8px 8px;
padding-bottom: 16px;
flex-shrink: 0;
}
.settings-btn {
opacity: 0.6;
justify-content: flex-start;
padding: 8px 16px !important;
border-radius: 20px !important;
}
.settings-btn:hover {
opacity: 1;
}
.settings-btn-collapsed {
width: 100%;
display: flex;
justify-content: center;
}
.chat-settings-group-trigger :deep(.v-list-item__append) {
display: flex;
align-items: center;
gap: 6px;
}
.chat-settings-group-current {
font-size: 14px;
line-height: 1;
opacity: 0.8;
}
.chat-settings-transport-current {
font-size: 12px;
}
.chat-settings-group-arrow {
opacity: 0.7;
}
.language-flag {
font-size: 16px;
margin-right: 8px;
}
.new-chat-row {
display: flex;
align-items: center;
gap: 4px;
}
.new-chat-row .new-chat-btn {
flex: 1;
min-width: 0;
}
.batch-action-bar {
display: flex;
align-items: center;
padding: 4px 12px;
gap: 4px;
flex-shrink: 0;
}
.batch-selected-count {
font-size: 12px;
opacity: 0.7;
white-space: nowrap;
}
.batch-checkbox {
flex: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot {
width: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
transform: translateX(-8px);
transition: width 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot--active {
width: 28px;
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,162 +1,228 @@
<template>
<div>
<!-- 项目按钮 -->
<div style="padding: 0 8px 0px 8px; opacity: 0.6;">
<v-btn block variant="text" class="project-btn" @click="toggleExpanded" prepend-icon="mdi-folder-outline">
{{ tm('project.title') }}
<template v-slot:append>
<v-icon size="small">{{ expanded ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</template>
</v-btn>
</div>
<!-- 项目列表 -->
<v-expand-transition>
<div v-show="expanded" style="padding: 0 8px;">
<v-list density="compact" nav class="project-list" style="background-color: transparent;">
<v-list-item @click="$emit('createProject')" class="create-project-item" rounded="lg">
<template v-slot:prepend>
<span class="project-emoji"><v-icon size="small">mdi-plus</v-icon></span>
</template>
<v-list-item-title style="font-size: 13px;">{{ tm('project.create') }}</v-list-item-title>
</v-list-item>
<v-list-item v-for="project in projects" :key="project.project_id"
@click="$emit('selectProject', project.project_id)" rounded="lg" class="project-item">
<template v-slot:prepend>
<span class="project-emoji">{{ project.emoji || '📁' }}</span>
</template>
<v-list-item-title class="project-title">{{ project.title }}</v-list-item-title>
<template v-slot:append>
<div class="project-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text" class="edit-project-btn"
@click.stop="$emit('editProject', project)" />
<v-btn icon="mdi-delete" size="x-small" variant="text" class="delete-project-btn"
color="error" @click.stop="handleDeleteProject(project)" />
</div>
</template>
</v-list-item>
</v-list>
</div>
</v-expand-transition>
<div class="project-list-shell">
<!-- 项目按钮 -->
<div class="project-button-wrap">
<v-btn block variant="text" class="project-btn" @click="toggleExpanded">
<v-icon size="20" class="project-action-icon mr-2">
mdi-folder-outline
</v-icon>
<span class="project-btn-title">{{ tm("project.title") }}</span>
<v-spacer />
<v-icon size="18" class="project-toggle-icon">
{{ expanded ? "mdi-chevron-up" : "mdi-chevron-down" }}
</v-icon>
</v-btn>
</div>
<!-- 项目列表 -->
<v-expand-transition>
<div v-show="expanded" class="project-list-wrap">
<button
class="project-row create-project-item"
type="button"
@click="$emit('createProject')"
>
<span class="project-emoji">
<v-icon size="18">mdi-plus</v-icon>
</span>
<span class="project-title">{{ tm("project.create") }}</span>
</button>
<button
v-for="project in projects"
:key="project.project_id"
class="project-row project-item"
:class="{ active: selectedProjectId === project.project_id }"
type="button"
@click="$emit('selectProject', project.project_id)"
>
<span class="project-emoji">{{ project.emoji || "📁" }}</span>
<span class="project-title">{{ project.title }}</span>
<span class="project-actions">
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
class="edit-project-btn"
@click.stop="$emit('editProject', project)"
/>
<v-btn
icon="mdi-delete"
size="x-small"
variant="text"
class="delete-project-btn"
color="error"
@click.stop="handleDeleteProject(project)"
/>
</span>
</button>
</div>
</v-expand-transition>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
import { ref } from "vue";
import { useModuleI18n } from "@/i18n/composables";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
export interface Project {
project_id: string;
title: string;
emoji?: string;
description?: string;
created_at: string;
updated_at: string;
project_id: string;
title: string;
emoji?: string;
description?: string;
created_at: string;
updated_at: string;
}
interface Props {
projects: Project[];
initialExpanded?: boolean;
projects: Project[];
initialExpanded?: boolean;
selectedProjectId?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
initialExpanded: false
initialExpanded: false,
selectedProjectId: null,
});
const emit = defineEmits<{
selectProject: [projectId: string];
createProject: [];
editProject: [project: Project];
deleteProject: [projectId: string];
selectProject: [projectId: string];
createProject: [];
editProject: [project: Project];
deleteProject: [projectId: string];
}>();
const { tm } = useModuleI18n('features/chat');
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
const expanded = ref(props.initialExpanded);
// 从 localStorage 读取项目展开状态
const savedProjectsExpandedState = localStorage.getItem('projectsExpanded');
const savedProjectsExpandedState = localStorage.getItem("projectsExpanded");
if (savedProjectsExpandedState !== null) {
expanded.value = JSON.parse(savedProjectsExpandedState);
expanded.value = JSON.parse(savedProjectsExpandedState);
}
function toggleExpanded() {
expanded.value = !expanded.value;
localStorage.setItem('projectsExpanded', JSON.stringify(expanded.value));
expanded.value = !expanded.value;
localStorage.setItem("projectsExpanded", JSON.stringify(expanded.value));
}
async function handleDeleteProject(project: Project) {
const message = tm('project.confirmDelete', { title: project.title });
if (await askForConfirmation(message, confirmDialog)) {
emit('deleteProject', project.project_id);
}
const message = tm("project.confirmDelete", { title: project.title });
if (await askForConfirmation(message, confirmDialog)) {
emit("deleteProject", project.project_id);
}
}
</script>
<style scoped>
.project-list-shell {
margin-top: 6px;
}
.project-button-wrap {
opacity: 0.6;
}
.project-btn {
justify-content: flex-start;
background-color: transparent !important;
border-radius: 20px;
padding: 8px 16px !important;
text-transform: none;
justify-content: flex-start;
background-color: transparent !important;
border-radius: 8px;
padding: 8px 12px !important;
text-transform: none;
font-weight: 500;
}
.project-item {
border-radius: 16px !important;
padding: 4px 12px !important;
margin-bottom: 2px;
.project-action-icon {
color: currentcolor;
}
.project-item:hover {
background-color: rgba(103, 58, 183, 0.05);
.project-btn-title {
min-width: 0;
}
.project-toggle-icon {
margin-left: 10px;
}
.project-list-wrap {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 8px;
}
.project-row {
width: 100%;
min-height: 38px;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
text-align: left;
}
.project-row:hover,
.project-row.active {
background: var(--chat-session-active-bg);
}
.project-item:hover .project-actions {
opacity: 1;
visibility: visible;
opacity: 1;
visibility: visible;
}
.project-emoji {
font-size: 16px;
margin-right: 6px;
width: 20px;
flex: 0 0 20px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.project-title {
font-size: 13px;
font-weight: 500;
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
}
.project-actions {
display: flex;
gap: 2px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
display: flex;
gap: 2px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.edit-project-btn,
.delete-project-btn {
opacity: 0.7;
transition: opacity 0.2s ease;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.edit-project-btn:hover,
.delete-project-btn:hover {
opacity: 1;
opacity: 1;
}
.create-project-item {
border-radius: 16px !important;
padding: 4px 12px !important;
opacity: 0.7;
opacity: 0.7;
}
.create-project-item:hover {
background-color: rgba(103, 58, 183, 0.08);
opacity: 1;
opacity: 1;
}
</style>

View File

@@ -1,189 +1,214 @@
<template>
<div class="project-sessions-container fade-in">
<div class="project-header">
<div class="project-header-info">
<span class="project-header-emoji">{{ project?.emoji || '📁' }}</span>
<h2 class="project-header-title">{{ project?.title }}</h2>
</div>
<p class="project-header-description" v-if="project?.description">
{{ project.description }}
</p>
</div>
<div class="project-input-slot">
<slot></slot>
</div>
<v-card flat class="project-sessions-list">
<v-list v-if="sessions.length > 0">
<v-list-item v-for="session in sessions" :key="session.session_id"
@click="$emit('selectSession', session.session_id)" class="project-session-item" rounded="lg">
<v-list-item-title>
{{ session.display_name || tm('conversation.newConversation') }}
</v-list-item-title>
<v-list-item-subtitle>
{{ formatDate(session.updated_at) }}
</v-list-item-subtitle>
<template v-slot:append>
<div class="session-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-session-btn"
@click.stop="$emit('editSessionTitle', session.session_id, session.display_name ?? '')" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-session-btn" color="error"
@click.stop="handleDeleteSession(session)" />
</div>
</template>
</v-list-item>
</v-list>
<div v-else class="no-sessions-in-project">
<v-icon icon="mdi-message-off-outline" size="large" color="grey-lighten-1"></v-icon>
<p>{{ tm('project.noSessions') }}</p>
</div>
</v-card>
<div class="project-sessions-container fade-in">
<div class="project-header">
<div class="project-header-info">
<span class="project-header-emoji">{{ project?.emoji || "📁" }}</span>
<h2 class="project-header-title">{{ project?.title }}</h2>
</div>
<p class="project-header-description" v-if="project?.description">
{{ project.description }}
</p>
</div>
<div class="project-input-slot">
<slot></slot>
</div>
<v-card flat class="project-sessions-list">
<v-list v-if="sessions.length > 0">
<v-list-item
v-for="session in sessions"
:key="session.session_id"
@click="$emit('selectSession', session.session_id)"
class="project-session-item"
rounded="lg"
>
<v-list-item-title>
{{ session.display_name || tm("conversation.newConversation") }}
</v-list-item-title>
<v-list-item-subtitle>
{{ formatDate(session.updated_at) }}
</v-list-item-subtitle>
<template v-slot:append>
<div class="session-actions">
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
class="edit-session-btn"
@click.stop="
$emit(
'editSessionTitle',
session.session_id,
session.display_name ?? '',
)
"
/>
<v-btn
icon="mdi-delete"
size="x-small"
variant="text"
class="delete-session-btn"
color="error"
@click.stop="handleDeleteSession(session)"
/>
</div>
</template>
</v-list-item>
</v-list>
<div v-else class="no-sessions-in-project">
<v-icon
icon="mdi-message-outline"
size="large"
color="grey-lighten-1"
></v-icon>
<p>{{ tm("project.noSessions") }}</p>
</div>
</v-card>
</div>
</template>
<script setup lang="ts">
import { useModuleI18n } from '@/i18n/composables';
import type { Project } from '@/components/chat/ProjectList.vue';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
import { useModuleI18n } from "@/i18n/composables";
import type { Project } from "@/components/chat/ProjectList.vue";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
interface Session {
session_id: string;
display_name?: string;
updated_at: string;
session_id: string;
display_name?: string | null;
updated_at: string;
}
interface Props {
project?: Project | null;
sessions: Session[];
project?: Project | null;
sessions: Session[];
}
defineProps<Props>();
const emit = defineEmits<{
selectSession: [sessionId: string];
editSessionTitle: [sessionId: string, title: string];
deleteSession: [sessionId: string];
selectSession: [sessionId: string];
editSessionTitle: [sessionId: string, title: string];
deleteSession: [sessionId: string];
}>();
const { tm } = useModuleI18n('features/chat');
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString();
return new Date(dateString).toLocaleString();
}
async function handleDeleteSession(session: Session) {
const sessionTitle = session.display_name || tm('conversation.newConversation');
const message = tm('conversation.confirmDelete', { name: sessionTitle });
if (await askForConfirmation(message, confirmDialog)) {
emit('deleteSession', session.session_id);
}
const sessionTitle =
session.display_name || tm("conversation.newConversation");
const message = tm("conversation.confirmDelete", { name: sessionTitle });
if (await askForConfirmation(message, confirmDialog)) {
emit("deleteSession", session.session_id);
}
}
</script>
<style scoped>
.project-sessions-container {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 32px;
overflow-y: auto;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 32px;
overflow-y: auto;
}
.project-header {
text-align: center;
margin-bottom: 32px;
max-width: 600px;
text-align: center;
margin-bottom: 32px;
max-width: 600px;
}
.project-header-info {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
}
.project-header-emoji {
font-size: 48px;
font-size: 48px;
}
.project-header-title {
font-size: 32px;
font-weight: 600;
font-size: 32px;
font-weight: 600;
}
.project-header-description {
font-size: 14px;
color: var(--v-theme-secondaryText);
margin: 0;
font-size: 14px;
color: var(--v-theme-secondaryText);
margin: 0;
}
.project-input-slot {
width: 100%;
max-width: 800px;
margin-bottom: 24px;
width: 100%;
max-width: 800px;
margin-bottom: 24px;
}
.project-sessions-list {
width: 100%;
max-width: 680px;
background-color: transparent !important;
width: 100%;
max-width: 680px;
background-color: transparent !important;
}
.project-session-item {
margin-bottom: 8px;
border-radius: 12px !important;
cursor: pointer;
margin-bottom: 8px;
border-radius: 12px !important;
cursor: pointer;
}
.project-session-item:hover {
background-color: rgba(103, 58, 183, 0.05);
background-color: rgba(103, 58, 183, 0.05);
}
.project-session-item:hover .session-actions {
opacity: 1;
visibility: visible;
opacity: 1;
visibility: visible;
}
.session-actions {
display: flex;
gap: 2px;
opacity: 1;
display: flex;
gap: 2px;
opacity: 1;
}
.no-sessions-in-project {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
opacity: 0.6;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
opacity: 0.6;
}
.no-sessions-in-project p {
margin-top: 12px;
font-size: 14px;
margin-top: 12px;
font-size: 14px;
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -1,356 +1,616 @@
<template>
<v-card class="standalone-chat-card" elevation="0" rounded="0">
<v-card-text class="standalone-chat-container">
<div class="chat-layout">
<!-- 聊天内容区域 -->
<div class="chat-content-panel">
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
ref="messageList" />
<div class="welcome-container fade-in" v-else>
<div class="welcome-title">
<span>Hello, I'm</span>
<span class="bot-name">AstrBot ⭐</span>
</div>
<p class="text-caption text-medium-emphasis mt-2">
测试配置: {{ configId || 'default' }}
</p>
</div>
<div class="standalone-chat">
<section ref="messagesContainer" class="standalone-messages">
<div v-if="initializing" class="standalone-state">
<v-progress-circular indeterminate size="28" width="3" />
</div>
<!-- 输入区域 -->
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="false"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:config-id="configId"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
@openLiveMode=""
ref="chatInputRef"
/>
</div>
<div v-else-if="!activeMessages.length" class="standalone-state">
<div class="welcome-title">{{ tm("welcome.title") }}</div>
</div>
<div v-else class="message-list">
<div
v-for="(msg, msgIndex) in activeMessages"
:key="msg.id || `${msgIndex}-${msg.created_at || ''}`"
class="message-row"
:class="isUserMessage(msg) ? 'from-user' : 'from-bot'"
>
<div class="message-stack">
<div
class="message-bubble"
:class="{ user: isUserMessage(msg), bot: !isUserMessage(msg) }"
>
<div v-if="messageContent(msg).isLoading" class="loading-message">
{{ tm("message.loading") }}
</div>
<template v-else>
<ReasoningBlock
v-if="messageContent(msg).reasoning"
:reasoning="messageContent(msg).reasoning || ''"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="hasNonReasoningContent(msg)"
/>
<template
v-for="(part, partIndex) in messageParts(msg)"
:key="`${msgIndex}-${partIndex}-${part.type}`"
>
<div
v-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="messageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
/>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
</div>
<div
v-else-if="part.type === 'tool_call'"
class="tool-call-block"
>
<template
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
>
<ToolCallItem
v-if="isIPythonToolCall(tool)"
:is-dark="isDark"
>
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
/>
</template>
</div>
<pre v-else class="unknown-part">{{ formatJson(part) }}</pre>
</template>
</template>
</div>
</v-card-text>
</v-card>
</div>
</div>
</div>
</section>
<!-- 图片预览对话框 -->
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
<v-card class="image-preview-card" elevation="8">
<v-card-title class="d-flex justify-space-between align-center pa-4">
<span>{{ t('core.common.imagePreview') }}</span>
<v-btn icon="mdi-close" variant="text" @click="imagePreviewDialog = false" />
</v-card-title>
<v-card-text class="text-center pa-4">
<img :src="previewImageUrl" class="preview-image-large" />
</v-card-text>
</v-card>
</v-dialog>
<section class="standalone-composer">
<ChatInput
ref="inputRef"
v-model:prompt="draft"
:staged-images-url="stagedImagesUrl"
:staged-audio-url="stagedAudioUrl"
:staged-files="stagedNonImageFiles"
:disabled="sending || initializing"
:enable-streaming="enableStreaming"
:is-recording="false"
:is-running="Boolean(currSessionId && isSessionRunning(currSessionId))"
:session-id="currSessionId || null"
:current-session="currentSession"
:config-id="configId || 'default'"
send-shortcut="enter"
@send="sendCurrentMessage"
@stop="stopCurrentSession"
@toggle-streaming="enableStreaming = !enableStreaming"
@remove-image="removeImage"
@remove-audio="removeAudio"
@remove-file="removeFile"
@paste-image="handlePaste"
@file-select="handleFilesSelected"
/>
</section>
<v-overlay
v-model="imagePreview.visible"
class="image-preview-overlay"
scrim="rgba(0, 0, 0, 0.86)"
@click="closeImage"
>
<img class="preview-image" :src="imagePreview.url" alt="preview" />
</v-overlay>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import axios from 'axios';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import MessageList from '@/components/chat/MessageList.vue';
import ChatInput from '@/components/chat/ChatInput.vue';
import { useMessages } from '@/composables/useMessages';
import { useMediaHandling } from '@/composables/useMediaHandling';
import { useRecording } from '@/composables/useRecording';
import { useToast } from '@/utils/toast';
import { buildWebchatUmoDetails } from '@/utils/chatConfigBinding';
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
} from "vue";
import axios from "axios";
import { MarkdownCodeBlockNode, setCustomComponents } from "markstream-vue";
import "markstream-vue/index.css";
import ChatInput from "@/components/chat/ChatInput.vue";
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.vue";
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import { useMediaHandling } from "@/composables/useMediaHandling";
import {
useMessages,
type ChatRecord,
type MessagePart,
type TransportMode,
} from "@/composables/useMessages";
import type { Session } from "@/composables/useSessions";
import { useModuleI18n } from "@/i18n/composables";
import { useCustomizerStore } from "@/stores/customizer";
import { buildWebchatUmoDetails } from "@/utils/chatConfigBinding";
interface Props {
configId?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
configId: null
const props = withDefaults(defineProps<{ configId?: string | null }>(), {
configId: "default",
});
const { t } = useI18n();
const { error: showError } = useToast();
setCustomComponents("chat-message", {
ref: RefNode,
code_block: MarkdownCodeBlockNode,
});
const { tm } = useModuleI18n("features/chat");
const customizer = useCustomizerStore();
const currSessionId = ref("");
const currentSession = ref<Session | null>(null);
const draft = ref("");
const initializing = ref(false);
const enableStreaming = ref(true);
const shouldStickToBottom = ref(true);
const messagesContainer = ref<HTMLElement | null>(null);
const inputRef = ref<InstanceType<typeof ChatInput> | null>(null);
const imagePreview = reactive({ visible: false, url: "" });
// UI 状态
const imagePreviewDialog = ref(false);
const previewImageUrl = ref('');
// 会话管理(不使用 useSessions 避免路由跳转)
const currSessionId = ref('');
const getCurrentSession = computed(() => null); // 独立测试模式不需要会话信息
async function bindConfigToSession(sessionId: string) {
const confId = (props.configId || '').trim();
if (!confId || confId === 'default') {
return;
}
const umoDetails = buildWebchatUmoDetails(sessionId, false);
await axios.post('/api/config/umo_abconf_route/update', {
umo: umoDetails.umo,
conf_id: confId
});
}
async function newSession() {
try {
const response = await axios.get('/api/chat/new_session');
const sessionId = response.data.data.session_id;
try {
await bindConfigToSession(sessionId);
} catch (err) {
console.error('Failed to bind config to session', err);
}
currSessionId.value = sessionId;
return sessionId;
} catch (err) {
console.error(err);
throw err;
}
}
function updateSessionTitle(sessionId: string, title: string) {
// 独立模式不需要更新会话标题
}
function getSessions() {
// 独立模式不需要加载会话列表
}
const isDark = computed(() => customizer.uiTheme === "PurpleThemeDark");
const customMarkdownTags = ["ref"];
const {
stagedImagesUrl,
stagedAudioUrl,
stagedFiles,
getMediaFile,
processAndUploadImage,
handlePaste,
removeImage,
removeAudio,
clearStaged,
cleanupMediaCache
stagedFiles,
stagedImagesUrl,
stagedAudioUrl,
stagedNonImageFiles,
processAndUploadImage,
processAndUploadFile,
handlePaste,
removeImage,
removeAudio,
removeFile,
clearStaged,
cleanupMediaCache,
} = useMediaHandling();
const { isRecording, startRecording: startRec, stopRecording: stopRec } = useRecording();
const {
messages,
isStreaming,
isConvRunning,
enableStreaming,
getSessionMessages: getSessionMsg,
sendMessage: sendMsg,
stopMessage: stopMsg,
toggleStreaming
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
// 组件引用
const messageList = ref<InstanceType<typeof MessageList> | null>(null);
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
// 输入状态
const prompt = ref('');
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
function openImagePreview(imageUrl: string) {
previewImageUrl.value = imageUrl;
imagePreviewDialog.value = true;
}
async function handleStartRecording() {
await startRec();
}
async function handleStopRecording() {
const audioFilename = await stopRec();
stagedAudioUrl.value = audioFilename;
}
async function handleFileSelect(files: FileList) {
for (const file of files) {
await processAndUploadImage(file);
sending,
activeMessages,
isSessionRunning,
isMessageStreaming,
isUserMessage,
messageContent,
messageParts,
createLocalExchange,
sendMessageStream,
stopSession,
} = useMessages({
currentSessionId: currSessionId,
onStreamUpdate: () => {
if (shouldStickToBottom.value) {
scrollToBottom();
}
}
},
});
async function handleSendMessage() {
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
return;
}
try {
if (!currSessionId.value) {
await newSession();
}
const promptToSend = prompt.value.trim();
const audioNameToSend = stagedAudioUrl.value;
const filesToSend = stagedFiles.value.map(f => ({
attachment_id: f.attachment_id,
url: f.url,
original_name: f.original_name,
type: f.type
}));
// 清空输入和附件
prompt.value = '';
clearStaged();
// 获取选择的提供商和模型
const selection = chatInputRef.value?.getCurrentSelection();
const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || '';
await sendMsg(
promptToSend,
filesToSend,
audioNameToSend,
selectedProviderId,
selectedModelName
);
// 滚动到底部
nextTick(() => {
messageList.value?.scrollToBottom();
});
} catch (err) {
console.error('Failed to send message:', err);
showError(t('features.chat.errors.sendMessageFailed'));
// 恢复输入内容,让用户可以重试
// 注意:附件已经上传到服务器,所以不恢复附件
}
}
async function handleStopMessage() {
await stopMsg();
}
const transportMode = computed<TransportMode>(() =>
(localStorage.getItem("chat.transportMode") as TransportMode) === "websocket"
? "websocket"
: "sse",
);
onMounted(async () => {
// 独立模式在挂载时创建新会话
try {
await newSession();
} catch (err) {
console.error('Failed to create initial session:', err);
showError(t('features.chat.errors.createSessionFailed'));
}
await ensureSession();
inputRef.value?.focusInput();
});
onBeforeUnmount(() => {
cleanupMediaCache();
cleanupMediaCache();
});
async function ensureSession() {
if (currSessionId.value) return currSessionId.value;
initializing.value = true;
try {
const response = await axios.get("/api/chat/new_session");
const session = response.data?.data as Session;
currSessionId.value = session.session_id;
currentSession.value = session;
await bindConfigToSession(session.session_id);
return session.session_id;
} finally {
initializing.value = false;
}
}
async function bindConfigToSession(sessionId: string) {
const confId = props.configId || "default";
const umo = buildWebchatUmoDetails(sessionId, false).umo;
await axios.post("/api/config/umo_abconf_route/update", {
umo,
conf_id: confId,
});
}
async function sendCurrentMessage() {
if (!draft.value.trim() && !stagedFiles.value.length) return;
const sessionId = await ensureSession();
const text = draft.value.trim();
const parts = buildOutgoingParts(text);
const messageId = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`;
const selection = inputRef.value?.getCurrentSelection();
const { botRecord } = createLocalExchange({ sessionId, messageId, parts });
draft.value = "";
clearStaged();
scrollToBottom();
sendMessageStream({
sessionId,
messageId,
parts,
transport: transportMode.value,
enableStreaming: enableStreaming.value,
selectedProvider: selection?.providerId || "",
selectedModel: selection?.modelName || "",
botRecord,
});
}
function buildOutgoingParts(text: string): MessagePart[] {
const parts: MessagePart[] = [];
if (text) {
parts.push({ type: "plain", text });
}
stagedFiles.value.forEach((file) => {
parts.push({
type: file.type,
attachment_id: file.attachment_id,
filename: file.filename,
embedded_url: file.url,
});
});
return parts;
}
function hasNonReasoningContent(message: ChatRecord) {
return messageParts(message).some((part) => {
if (part.type === "reply") return false;
if (part.type === "plain") return Boolean(String(part.text || "").trim());
return true;
});
}
async function stopCurrentSession() {
if (!currSessionId.value) return;
await stopSession(currSessionId.value);
}
async function handleFilesSelected(files: FileList) {
const selectedFiles = Array.from(files || []);
for (const file of selectedFiles) {
if (file.type.startsWith("image/")) {
await processAndUploadImage(file);
} else {
await processAndUploadFile(file);
}
}
}
function scrollToBottom() {
nextTick(() => {
const container = messagesContainer.value;
if (!container) return;
container.scrollTop = container.scrollHeight;
shouldStickToBottom.value = true;
});
}
function messageRefs(message: ChatRecord) {
const refs = messageContent(message).refs;
if (refs && typeof refs === "object" && Array.isArray(refs.used)) {
return refs as { used?: Array<Record<string, unknown>> };
}
return null;
}
function partUrl(part: MessagePart) {
if (part.embedded_url) return part.embedded_url;
if (part.embedded_file?.url) return part.embedded_file.url;
if (part.attachment_id)
return `/api/chat/get_attachment?attachment_id=${encodeURIComponent(
part.attachment_id,
)}`;
if (part.filename)
return `/api/chat/get_file?filename=${encodeURIComponent(part.filename)}`;
return "";
}
function normalizeToolCall(tool: Record<string, unknown>) {
const normalized = { ...tool };
normalized.args = parseJsonSafe(normalized.args || normalized.arguments);
normalized.result = parseJsonSafe(normalized.result);
if (!normalized.ts) normalized.ts = Date.now() / 1000;
if (normalized.result && typeof normalized.result === "object") {
normalized.result = JSON.stringify(normalized.result, null, 2);
}
return normalized;
}
function isIPythonToolCall(tool: Record<string, unknown>) {
const name = String(tool.name || "").toLowerCase();
return name.includes("python") || name.includes("ipython");
}
function toolCallStatusText(tool: Record<string, unknown>) {
if (tool.finished_ts) return tm("toolStatus.done");
return tm("toolStatus.running");
}
function formatJson(value: unknown) {
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value ?? "");
}
}
function parseJsonSafe(value: unknown) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function openImage(url: string) {
imagePreview.url = url;
imagePreview.visible = true;
}
function closeImage() {
imagePreview.visible = false;
imagePreview.url = "";
}
</script>
<style scoped>
/* 基础动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
.standalone-chat {
--standalone-muted: rgba(var(--v-theme-on-surface), 0.62);
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: rgb(var(--v-theme-background));
}
.standalone-chat-card {
width: 100%;
height: 100%;
max-height: 100%;
overflow: hidden;
.standalone-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px 22px 14px;
}
.standalone-chat-container {
width: 100%;
height: 100%;
max-height: 100%;
padding: 0;
overflow: hidden;
}
.chat-layout {
height: 100%;
max-height: 100%;
display: flex;
overflow: hidden;
}
.chat-content-panel {
height: 100%;
max-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
padding-left: 16px;
border-bottom: 1px solid var(--v-theme-border);
width: 100%;
padding-right: 32px;
flex-shrink: 0;
}
.conversation-header-info h4 {
margin: 0;
font-weight: 500;
}
.conversation-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.welcome-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
.standalone-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.welcome-title {
font-size: 28px;
margin-bottom: 8px;
font-size: 24px;
font-weight: 700;
}
.bot-name {
font-weight: 700;
margin-left: 8px;
color: var(--v-theme-secondary);
.message-list {
display: flex;
flex-direction: column;
gap: 18px;
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
.message-row {
display: flex;
}
.preview-image-large {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
.message-row.from-user {
justify-content: flex-end;
}
.message-stack {
max-width: 88%;
}
.from-user .message-stack {
max-width: 70%;
}
.message-bubble {
border-radius: 8px;
padding: 10px 14px;
line-height: 1.65;
overflow-wrap: anywhere;
}
.message-bubble.user {
padding: 12px 18px;
border-radius: 1.5rem;
background: rgba(var(--v-theme-primary), 0.12);
}
.message-bubble.bot {
padding-left: 0;
background: transparent;
}
.plain-content {
white-space: pre-wrap;
}
.loading-message,
.tool-call-inline-status {
color: var(--standalone-muted);
}
.image-part {
display: block;
border: 0;
padding: 0;
margin-top: 8px;
background: transparent;
cursor: zoom-in;
}
.image-part img {
max-width: min(360px, 100%);
max-height: 320px;
border-radius: 8px;
object-fit: contain;
}
.audio-part,
.video-part {
display: block;
max-width: 100%;
margin-top: 8px;
}
.video-part {
max-height: 320px;
border-radius: 8px;
}
.file-part {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.tool-call-block {
margin: 8px 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.message-bubble.bot
> .tool-call-block:first-child
:deep(.tool-call-card:first-child) {
margin-top: 0;
}
.unknown-part {
max-width: 100%;
overflow-x: auto;
border-radius: 8px;
padding: 10px;
background: rgba(var(--v-theme-on-surface), 0.06);
font-size: 13px;
line-height: 1.5;
}
.standalone-composer {
position: relative;
z-index: 1;
padding-bottom: 10px;
background: rgb(var(--v-theme-background));
}
.standalone-composer::before {
content: "";
position: absolute;
z-index: -1;
left: 0;
right: 0;
top: -32px;
height: 32px;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 0),
rgb(var(--v-theme-background))
);
}
.standalone-composer :deep(.input-area) {
border-top: 0;
}
.image-preview-overlay {
display: flex;
align-items: center;
justify-content: center;
}
.preview-image {
max-width: min(92vw, 1000px);
max-height: 88vh;
border-radius: 8px;
object-fit: contain;
}
</style>

View File

@@ -1,144 +0,0 @@
<template>
<div class="welcome-container fade-in">
<div v-if="isLoading" class="loading-overlay-welcome">
<v-progress-circular
indeterminate
size="48"
width="4"
color="primary"
></v-progress-circular>
</div>
<template v-else>
<div class="welcome-content">
<div class="welcome-title">
<span class="bot-name-container">
<span class="bot-name-text">
Hello, I'm <span class="highlight-name">AstrBot</span>
</span>
<span class="bot-name-star"></span>
</span>
</div>
</div>
<div class="welcome-input">
<slot></slot>
</div>
</template>
</div>
</template>
<script setup lang="ts">
interface Props {
isLoading?: boolean;
}
withDefaults(defineProps<Props>(), {
isLoading: false
});
</script>
<style scoped>
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.welcome-container {
height: 100%;
width: 100%;
justify-content: center;
display: flex;
align-items: center;
flex-direction: column;
position: relative;
}
.welcome-content {
padding: 24px 0px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.welcome-title {
font-size: 28px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.welcome-input {
width: 75%;
}
.loading-overlay-welcome {
display: flex;
justify-content: center;
align-items: center;
}
.bot-name-container {
display: flex;
align-items: center;
}
.highlight-name {
color: var(--v-theme-secondary);
font-weight: 700;
}
.bot-name-text {
overflow: hidden;
white-space: nowrap;
width: 0;
opacity: 0;
animation: revealText 1.2s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
animation-delay: 0.2s;
}
.bot-name-star {
margin-left: 0;
display: inline-block;
transform-origin: center;
animation: rotateStar 1.2s cubic-bezier(0.34, 1, 0.64, 1) forwards;
animation-delay: 0.2s;
padding-left: 4px;
}
@keyframes revealText {
from {
width: 0;
opacity: 0;
}
to {
width: 9.2em;
opacity: 1;
}
}
@keyframes rotateStar {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@media (max-width: 600px) {
.welcome-input {
width: 100%;
}
}
</style>

View File

@@ -1,109 +1,121 @@
<template>
<div v-if="refs && refs.used && refs.used.length > 0" class="refs-container" @click="handleClick">
<div class="refs-avatars">
<div v-for="(ref, refIdx) in refs.used.slice(0, 3)" :key="refIdx" class="ref-avatar"
:style="{ zIndex: 3 - refIdx }">
<img v-if="ref.favicon" :src="ref.favicon" class="ref-favicon"
@error="(e) => e.target.style.display = 'none'" />
<span v-else class="ref-initial">{{ getRefInitial(ref.title) }}</span>
</div>
<span v-if="refs.used.length > 3" class="refs-more">
+{{ refs.used.length - 3 }}
</span>
<span class="ml-2" style="color: gray;">
{{ tm('refs.sources') }}
</span>
</div>
<div
v-if="refs && refs.used && refs.used.length > 0"
class="refs-container"
@click="handleClick"
>
<div class="refs-avatars">
<div
v-for="(ref, refIdx) in refs.used.slice(0, 3)"
:key="refIdx"
class="ref-avatar"
:style="{ zIndex: 3 - refIdx }"
>
<img
v-if="ref.favicon"
:src="ref.favicon"
class="ref-favicon"
@error="(e) => (e.target.style.display = 'none')"
/>
<span v-else class="ref-initial">{{ getRefInitial(ref.title) }}</span>
</div>
<span v-if="refs.used.length > 3" class="refs-more">
+{{ refs.used.length - 3 }}
</span>
<span class="ml-2" style="color: gray">
{{ tm("refs.sources") }}
</span>
</div>
</div>
</template>
<script>
import { useModuleI18n } from '@/i18n/composables';
import { useModuleI18n } from "@/i18n/composables";
export default {
name: 'ActionRef',
props: {
refs: {
type: Object,
default: null
}
name: "ActionRef",
props: {
refs: {
type: Object,
default: null,
},
emits: ['open-refs'],
setup() {
const { tm } = useModuleI18n('features/chat');
return { tm };
},
emits: ["open-refs"],
setup() {
const { tm } = useModuleI18n("features/chat");
return { tm };
},
methods: {
// Get first character of ref title for fallback display
getRefInitial(title) {
if (!title) return "?";
return title.charAt(0).toUpperCase();
},
methods: {
// Get first character of ref title for fallback display
getRefInitial(title) {
if (!title) return '?';
return title.charAt(0).toUpperCase();
},
// Handle click to open refs sidebar
handleClick() {
this.$emit('open-refs', this.refs);
}
}
}
// Handle click to open refs sidebar
handleClick() {
this.$emit("open-refs", this.refs);
},
},
};
</script>
<style scoped>
.refs-container {
display: flex;
align-items: center;
margin-left: 8px;
padding: 4px 8px;
border-radius: 12px;
cursor: pointer;
transition: background-color;
display: flex;
align-items: center;
margin-left: 8px;
padding: 4px 8px;
border-radius: 12px;
cursor: pointer;
transition: background-color;
}
.refs-container:hover {
background-color: rgba(103, 58, 183, 0.08);
background-color: rgba(103, 58, 183, 0.08);
}
.refs-avatars {
display: flex;
align-items: center;
position: relative;
display: flex;
align-items: center;
position: relative;
}
.ref-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
opacity: 0.9;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
width: 20px;
height: 20px;
border-radius: 50%;
opacity: 0.9;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
.ref-avatar:not(:first-child) {
margin-left: -8px;
margin-left: -8px;
}
.ref-favicon {
width: 100%;
height: 100%;
object-fit: cover;
width: 100%;
height: 100%;
object-fit: cover;
}
.ref-initial {
font-size: 10px;
font-weight: 600;
color: white;
user-select: none;
font-size: 10px;
font-weight: 600;
color: white;
user-select: none;
}
.refs-more {
margin-left: 6px;
font-size: 11px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
font-weight: 500;
margin-left: 6px;
font-size: 11px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div class="markdown-content">
<MarkdownRender
custom-id="chat-message"
:content="content"
:custom-html-tags="customHtmlTags"
:is-dark="isDark"
:typewriter="false"
:max-live-nodes="0"
/>
</div>
</template>
<script setup lang="ts">
import { computed, provide } from "vue";
import { MarkdownRender } from "markstream-vue";
const props = defineProps<{
content: string;
refs: { used?: Array<Record<string, unknown>> } | null;
isDark: boolean;
customHtmlTags: string[];
}>();
const isDarkRef = computed(() => props.isDark);
const refsByIndex = computed(() => {
const messageRefs = props.refs;
const refs =
messageRefs && Array.isArray(messageRefs.used) ? messageRefs.used : [];
return refs.reduce<Record<string, Record<string, unknown>>>((acc, item) => {
if (item.index != null) {
acc[String(item.index)] = item;
}
return acc;
}, {});
});
provide("isDark", isDarkRef);
provide("webSearchResults", () => refsByIndex.value);
</script>

View File

@@ -1,433 +0,0 @@
<template>
<template v-for="(renderPart, renderIndex) in getRenderParts(parts)" :key="renderPart.key">
<!-- Grouped Tool Calls (consecutive tool_call parts) -->
<div v-if="renderPart.type === 'tool_group'" class="tool-call-compact">
<transition-group name="tool-call-item" tag="div" class="tool-call-items">
<ToolCallItem v-for="(toolCall, tcIndex) in renderPart.toolCalls" :key="toolCall.id" :is-dark="isDark">
<template #label="{ expanded }">
<v-icon size="x-small" v-if="toolCall.name.includes('web_search') || toolCall.name.includes('tavily')">
mdi-web
</v-icon>
<v-icon size="x-small" v-else-if="toolCall.name === 'astrbot_execute_shell'">
mdi-console-line
</v-icon>
<v-icon size="x-small" v-else>
mdi-wrench
</v-icon>
{{ tm('actions.toolCallUsed', { name: toolCall.name }) }}
<span style="opacity: 0.6;">{{ toolCall.finished_ts ? formatDuration(toolCall.finished_ts -
toolCall.ts) : getElapsedTime(toolCall.ts) }}</span>
<v-icon size="x-small" class="tool-call-chevron" :class="{ rotated: expanded }">
mdi-chevron-right
</v-icon>
</template>
<template #details>
<div class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value">{{ toolCall.id }}</code>
</div>
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json">{{ formatToolArgs(toolCall.args) }}</pre>
</div>
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre
class="detail-value detail-json detail-result">{{ formatToolResult(toolCall.result) }}</pre>
</div>
</template>
</ToolCallItem>
</transition-group>
</div>
<!-- iPython Tool Block -->
<ToolCallItem v-else-if="renderPart.type === 'ipython'" :is-dark="isDark" style="margin: 8px 0 4px;">
<template #label="{ expanded }">
<v-icon size="x-small">
mdi-code-json
</v-icon>
<span class="ipython-label">{{ tm('actions.pythonCodeAnalysis') }}</span>
<span style="opacity: 0.6;">{{ renderPart.toolCall.finished_ts ?
formatDuration(renderPart.toolCall.finished_ts -
renderPart.toolCall.ts) : getElapsedTime(renderPart.toolCall.ts) }}</span>
<v-icon size="small" class="ipython-icon" :class="{ rotated: expanded }">
mdi-chevron-right
</v-icon>
</template>
<template #details>
<IPythonToolBlock :tool-call="renderPart.toolCall" :is-dark="isDark" :show-header="false"
:force-expanded="true" />
</template>
</ToolCallItem>
<!-- Text (Markdown) -->
<MarkdownRender
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
custom-id="message-list" :custom-html-tags="['ref']"
:content="normalizeMarkdownContent(renderPart.part.text)" :typewriter="false"
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }"
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"/>
<!-- Image -->
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
<div class="embedded-image">
<img :src="renderPart.part.embedded_url" class="bot-embedded-image"
@click="emitOpenImage(renderPart.part.embedded_url)" />
</div>
</div>
<!-- Audio -->
<div v-else-if="renderPart.part.type === 'record' && renderPart.part.embedded_url" class="embedded-audio">
<audio controls class="audio-player">
<source :src="renderPart.part.embedded_url" type="audio/wav">
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
<!-- Files -->
<div v-else-if="renderPart.part.type === 'file' && renderPart.part.embedded_file" class="embedded-files">
<div class="embedded-file">
<a v-if="renderPart.part.embedded_file.url" :href="renderPart.part.embedded_file.url"
:download="renderPart.part.embedded_file.filename" class="file-link" :class="{ 'is-dark': isDark }"
:style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ renderPart.part.embedded_file.filename }}</span>
</a>
<a v-else @click="emitDownloadFile(renderPart.part.embedded_file)" class="file-link file-link-download"
:class="{ 'is-dark': isDark }" :style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ renderPart.part.embedded_file.filename }}</span>
<v-icon v-if="downloadingFiles?.has(renderPart.part.embedded_file.attachment_id)" size="small"
class="download-icon">mdi-loading mdi-spin</v-icon>
<v-icon v-else size="small" class="download-icon">mdi-download</v-icon>
</a>
</div>
</div>
</template>
</template>
<script setup>
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { MarkdownRender } from 'markstream-vue';
import IPythonToolBlock from './IPythonToolBlock.vue';
import ToolCallItem from './ToolCallItem.vue';
const props = defineProps({
parts: {
type: Array,
required: true
},
isDark: {
type: Boolean,
default: false
},
currentTime: {
type: Number,
default: 0
},
downloadingFiles: {
type: Object,
default: () => new Set()
}
});
const emit = defineEmits(['open-image-preview', 'download-file']);
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const emitOpenImage = (url) => {
emit('open-image-preview', url);
};
const emitDownloadFile = (file) => {
emit('download-file', file);
};
const isMarkdownCodeFence = (text) => /^(```|~~~)/.test(text.trim());
const looksLikeStandaloneHtml = (text) => {
const normalized = text.trim();
if (!normalized) return false;
if (!/(<!doctype\s+html|<html\b|<head\b|<body\b)/i.test(normalized)) return false;
return /(<\/html>|<\/body>|<\/head>|<form\b|<input\b|<button\b)/i.test(normalized);
};
const normalizeMarkdownContent = (text) => {
if (typeof text !== 'string') return text;
if (isMarkdownCodeFence(text) || !looksLikeStandaloneHtml(text)) return text;
return `\`\`\`\`html\n${text}\n\`\`\`\``;
};
const formatDuration = (seconds) => {
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`;
}
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
}
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
};
const getElapsedTime = (startTs) => {
const elapsed = props.currentTime - startTs;
return formatDuration(elapsed);
};
const formatToolResult = (result) => {
if (!result) return '';
if (typeof result === 'string') {
try {
const parsed = JSON.parse(result);
return JSON.stringify(parsed, null, 2);
} catch {
return result;
}
}
return JSON.stringify(result, null, 2);
};
const formatToolArgs = (args) => {
if (!args) return '';
if (typeof args === 'string') {
try {
const parsed = JSON.parse(args);
return JSON.stringify(parsed, null, 2);
} catch {
return args;
}
}
return JSON.stringify(args, null, 2);
};
const isIPythonTool = (toolCall) => {
return toolCall.name === 'astrbot_execute_ipython' || toolCall.name === 'astrbot_execute_python';
};
const getRenderParts = (messageParts) => {
if (!Array.isArray(messageParts)) return [];
const rendered = [];
let pendingToolCalls = [];
let groupIndex = 0;
const flushPending = (endIndex) => {
if (!pendingToolCalls.length) return;
rendered.push({
type: 'tool_group',
toolCalls: pendingToolCalls,
key: `tool-group-${groupIndex}-${endIndex}`
});
pendingToolCalls = [];
groupIndex += 1;
};
messageParts.forEach((part, idx) => {
if (part?.type === 'tool_call' && Array.isArray(part.tool_calls) && part.tool_calls.length) {
part.tool_calls.forEach((toolCall, tcIndex) => {
if (isIPythonTool(toolCall)) {
flushPending(idx - 1);
rendered.push({
type: 'ipython',
toolCall,
key: `ipython-${idx}-${tcIndex}`
});
return;
}
pendingToolCalls.push(toolCall);
});
return;
}
flushPending(idx - 1);
rendered.push({
type: 'part',
part,
key: `part-${idx}`
});
});
flushPending(messageParts.length - 1);
return rendered;
};
</script>
<style scoped>
.tool-call-compact {
display: flex;
flex-direction: column;
gap: 8px;
margin: 8px 0 4px;
}
.tool-call-group-title {
font-size: 13px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
}
.tool-call-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.tool-call-detail-row {
display: flex;
flex-direction: column;
margin-bottom: 6px;
}
.tool-call-detail-row:last-child {
margin-bottom: 0;
}
.detail-label {
font-size: 11px;
font-weight: 600;
color: var(--v-theme-secondaryText);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.detail-value {
font-size: 12px;
color: var(--v-theme-primaryText);
background-color: transparent;
padding: 2px 6px;
border-radius: 4px;
word-break: break-word;
}
.detail-json {
font-family: 'Fira Code', 'Consolas', monospace;
white-space: pre-wrap;
max-height: 220px;
overflow-y: auto;
margin: 0;
}
.detail-result {
max-height: 320px;
background-color: transparent;
}
.tool-call-item-enter-active,
.tool-call-item-leave-active {
transition: all 0.2s ease;
}
.tool-call-item-enter-from,
.tool-call-item-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.ipython-icon,
.tool-call-chevron {
margin-left: 6px;
transition: transform 0.2s ease;
}
.ipython-icon.rotated {
transform: rotate(90deg);
}
.tool-call-chevron.rotated {
transform: rotate(90deg);
}
.embedded-images {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.embedded-image {
display: flex;
justify-content: flex-start;
}
.bot-embedded-image {
max-width: 55%;
width: auto;
height: auto;
border-radius: 4px;
cursor: pointer;
transition: transform 0.2s ease;
}
.embedded-audio {
width: 300px;
margin-top: 8px;
}
.embedded-audio .audio-player {
width: 100%;
max-width: 300px;
}
/* 文件附件样式 */
.file-attachments,
.embedded-files {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.file-attachment,
.embedded-file {
display: flex;
align-items: center;
}
/* 文件附件样式 */
.file-attachments,
.embedded-files {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.file-attachment,
.embedded-file {
display: flex;
align-items: center;
}
.file-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background-color: rgba(var(--v-theme-primary), 0.08);
border: 1px solid rgba(var(--v-theme-primary), 0.2);
border-radius: 8px;
text-decoration: none;
font-size: 13px;
transition: all 0.2s ease;
max-width: 320px;
}
.file-link-download {
cursor: pointer;
}
</style>

View File

@@ -1,122 +1,288 @@
<template>
<div class="reasoning-block" :class="{ 'reasoning-block--dark': isDark }">
<div class="reasoning-header" @click="toggleExpanded">
<v-icon size="small" class="reasoning-icon" :class="{ 'rotate-90': isExpanded }">
mdi-chevron-right
</v-icon>
<span class="reasoning-title">
{{ tm('reasoning.thinking') }}
</span>
</div>
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
<MarkdownRender :key="`reasoning-${isDark ? 'dark' : 'light'}`" :content="reasoning" class="reasoning-text markdown-content"
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
</div>
<div class="reasoning-block" :class="{ 'reasoning-block--dark': isDark }">
<button class="reasoning-header" type="button" @click="toggleExpanded">
<span class="reasoning-title">
{{ tm("reasoning.thinking") }}
</span>
<v-icon
size="22"
class="reasoning-icon"
:class="{ 'rotate-90': isExpanded }"
>
mdi-chevron-right
</v-icon>
</button>
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
<MarkdownRender
:key="`reasoning-${isDark ? 'dark' : 'light'}`"
:content="reasoning"
class="reasoning-text markdown-content"
:typewriter="false"
:is-dark="isDark"
/>
</div>
<transition :name="previewTransitionName" mode="out-in">
<div
v-if="showStreamingPreview"
:key="previewKey"
class="reasoning-preview"
>
{{ previewText }}
</div>
</transition>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { MarkdownRender } from 'markstream-vue';
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useModuleI18n } from "@/i18n/composables";
import { MarkdownRender } from "markstream-vue";
const props = defineProps({
reasoning: {
type: String,
required: true
},
isDark: {
type: Boolean,
default: false
},
initialExpanded: {
type: Boolean,
default: false
}
reasoning: {
type: String,
required: true,
},
isDark: {
type: Boolean,
default: false,
},
initialExpanded: {
type: Boolean,
default: false,
},
isStreaming: {
type: Boolean,
default: false,
},
hasNonReasoningContent: {
type: Boolean,
default: false,
},
});
const { tm } = useModuleI18n('features/chat');
const { tm } = useModuleI18n("features/chat");
const isExpanded = ref(props.initialExpanded);
const previewText = ref("");
const previewKey = ref(0);
let previewTimer = null;
let previewStartTimer = null;
const showStreamingPreview = computed(
() =>
props.isStreaming &&
!isExpanded.value &&
!props.hasNonReasoningContent &&
previewText.value,
);
const previewTransitionName = computed(() =>
props.hasNonReasoningContent
? "reasoning-preview-collapse"
: "reasoning-preview-fade",
);
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
isExpanded.value = !isExpanded.value;
};
const latestReasoningPreview = () => {
const lines = props.reasoning
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
return lines.slice(-3).join("\n");
};
const updatePreviewLine = () => {
const nextText = latestReasoningPreview();
if (!nextText || nextText === previewText.value) return;
previewText.value = nextText;
previewKey.value += 1;
};
const stopPreviewTimer = () => {
if (!previewTimer) return;
clearInterval(previewTimer);
previewTimer = null;
};
const stopPreviewStartTimer = () => {
if (!previewStartTimer) return;
clearTimeout(previewStartTimer);
previewStartTimer = null;
};
const startPreviewTimer = () => {
updatePreviewLine();
if (!previewTimer) {
previewTimer = setInterval(updatePreviewLine, 2000);
}
};
const syncPreviewTimer = () => {
if (props.isStreaming && !isExpanded.value && !props.hasNonReasoningContent) {
if (!previewTimer && !previewStartTimer) {
previewStartTimer = setTimeout(() => {
previewStartTimer = null;
if (
props.isStreaming &&
!isExpanded.value &&
!props.hasNonReasoningContent
) {
startPreviewTimer();
}
}, 2000);
}
return;
}
stopPreviewStartTimer();
stopPreviewTimer();
if (!props.isStreaming) {
previewText.value = "";
}
};
watch(
() => [props.isStreaming, isExpanded.value, props.hasNonReasoningContent],
syncPreviewTimer,
{
immediate: true,
},
);
onBeforeUnmount(() => {
stopPreviewStartTimer();
stopPreviewTimer();
});
</script>
<style scoped>
/* Reasoning 区块样式 */
.reasoning-container {
margin-bottom: 12px;
margin-top: 6px;
border: 1px solid var(--v-theme-border);
border-radius: 20px;
overflow: hidden;
width: fit-content;
.reasoning-block {
margin: 6px 0;
max-width: 100%;
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: inherit;
line-height: inherit;
}
.reasoning-header {
display: inline-flex;
align-items: center;
padding: 8px 8px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
border-radius: 20px;
max-width: 100%;
border: 0;
padding: 0;
background: transparent;
color: inherit;
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
gap: 8px;
font: inherit;
text-align: left;
}
.reasoning-header:hover {
background-color: rgba(103, 58, 183, 0.08);
}
.reasoning-header.is-dark:hover {
background-color: rgba(103, 58, 183, 0.15);
color: rgba(var(--v-theme-on-surface), 0.88);
}
.reasoning-icon {
margin-right: 6px;
color: var(--v-theme-secondary);
transition: transform 0.2s ease;
color: currentcolor;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.reasoning-label {
font-size: 13px;
font-weight: 500;
color: var(--v-theme-secondary);
letter-spacing: 0.3px;
.reasoning-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reasoning-content {
padding: 0px 12px;
border-top: 1px solid var(--v-theme-border);
color: gray;
animation: fadeIn 0.2s ease-in-out;
font-style: italic;
margin-top: 8px;
padding: 0;
color: rgba(var(--v-theme-on-surface), 0.7);
animation: fadeIn 0.2s ease-in-out;
font-style: italic;
}
.reasoning-preview {
max-width: 100%;
margin-top: 4px;
color: rgba(var(--v-theme-on-surface), 0.52);
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
white-space: pre-line;
font: inherit;
font-style: italic;
}
.reasoning-text {
font-size: 14px;
line-height: 1.6;
color: var(--v-theme-secondaryText);
font-size: inherit;
line-height: inherit;
color: inherit;
}
.animate-fade-in {
animation: fadeIn 0.2s ease-in-out;
animation: fadeIn 0.2s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
from {
opacity: 0;
}
to {
opacity: 1;
}
to {
opacity: 1;
}
}
.rotate-90 {
transform: rotate(90deg);
transform: rotate(90deg);
}
.reasoning-preview-fade-enter-active {
transition: opacity 0.25s ease;
}
.reasoning-preview-fade-leave-active {
transition: opacity 0.25s ease;
}
.reasoning-preview-fade-enter-from,
.reasoning-preview-fade-leave-to {
opacity: 0;
}
.reasoning-preview-collapse-enter-active {
transition: opacity 0.25s ease;
}
.reasoning-preview-collapse-leave-active {
overflow: hidden;
transition:
max-height 0.45s cubic-bezier(0.55, 0, 1, 0.45),
margin-top 0.45s cubic-bezier(0.55, 0, 1, 0.45),
opacity 0.35s ease-in,
transform 0.45s cubic-bezier(0.55, 0, 1, 0.45);
}
.reasoning-preview-collapse-enter-from {
opacity: 0;
}
.reasoning-preview-collapse-leave-from {
max-height: 5rem;
opacity: 1;
transform: translateY(0);
}
.reasoning-preview-collapse-leave-to {
max-height: 0;
margin-top: 0;
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -1,76 +1,91 @@
<template>
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
:style="chipStyle" :href="url"
target="_blank" clickable>
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
<span>{{ domain }}</span>
</v-chip>
<span v-else class="ref-fallback" :style="fallbackStyle">{{ 'site' }}</span>
<v-chip
v-if="resultData"
class="ref-chip"
size="x-small"
variant="flat"
:style="chipStyle"
:href="url"
target="_blank"
clickable
>
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
<span>{{ domain || resultData.title || refIndex }}</span>
</v-chip>
</template>
<script setup>
import { computed, inject } from 'vue'
import { computed, inject, unref, useSlots } from "vue";
const props = defineProps({
node: {
type: Object,
required: true
}
})
node: {
type: Object,
default: null,
},
});
console.log('RefNode node:', props.node);
const slots = useSlots();
const injectedIsDark = inject("isDark", false);
const webSearchResults = inject("webSearchResults", () => ({}));
// 从父组件注入的暗黑模式状态和搜索结果
const isDark = inject('isDark', false)
const webSearchResults = inject('webSearchResults', () => ({}))
const isDark = computed(() => Boolean(unref(injectedIsDark)));
const refIndex = computed(() => {
const nodeContent = props.node?.content?.trim();
if (nodeContent) return nodeContent;
return slotText(slots.default?.()).trim();
});
// 从 node.content 中提取 ref index (格式: uuid.idx)
const refIndex = computed(() => props.node?.content?.trim() || '')
// 根据 refIndex 查找对应的 URL
const resultData = computed(() => {
if (!refIndex.value) return null
const results = typeof webSearchResults === 'function' ? webSearchResults() : webSearchResults
return results?.[refIndex.value] || null
})
if (!refIndex.value) return null;
const results =
typeof webSearchResults === "function"
? webSearchResults()
: webSearchResults;
return results?.[refIndex.value] || null;
});
const url = computed(() => resultData.value?.url || '')
const url = computed(() => resultData.value?.url || "");
const domain = computed(() => {
if (!url.value) return ''
try {
const urlObj = new URL(url.value)
return urlObj.hostname.replace(/^www\./, '')
} catch (e) {
return ''
}
})
if (!url.value) return "";
try {
const urlObj = new URL(url.value);
return urlObj.hostname.replace(/^www\./, "");
} catch (e) {
return "";
}
});
const chipStyle = computed(() => ({
backgroundColor: isDark ? 'rgba(var(--v-theme-on-surface), 0.08)' : 'rgba(var(--v-theme-on-surface), 0.04)',
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
}))
backgroundColor: isDark.value
? "rgba(var(--v-theme-on-surface), 0.08)"
: "rgba(var(--v-theme-on-surface), 0.04)",
color: isDark.value
? "rgba(var(--v-theme-on-surface), 0.62)"
: "rgba(var(--v-theme-on-surface), 0.72)",
}));
const fallbackStyle = computed(() => ({
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
}))
function slotText(nodes = []) {
return nodes
.map((node) => {
if (typeof node.children === "string") return node.children;
if (Array.isArray(node.children)) return slotText(node.children);
return "";
})
.join("");
}
</script>
<style scoped>
.ref-chip {
margin: 0 2px;
cursor: pointer;
text-decoration: none;
transition: opacity;
margin-left: 4px;
margin: 0 2px;
cursor: pointer;
text-decoration: none;
transition: opacity;
margin-left: 4px;
}
.ref-chip:hover {
opacity: 0.8;
}
.ref-fallback {
font-size: 0.9em;
opacity: 0.8;
}
</style>

View File

@@ -1,225 +1,262 @@
<template>
<transition name="slide-left">
<div v-if="isOpen" class="refs-sidebar">
<div class="sidebar-header">
<h3 class="sidebar-title">{{ tm('refs.title') }}</h3>
<v-btn icon="mdi-close" size="small" variant="text" @click="close"></v-btn>
</div>
<transition name="slide-left">
<div v-if="isOpen" class="refs-sidebar">
<div class="sidebar-header">
<h3 class="sidebar-title">{{ tm("refs.title") }}</h3>
<v-btn
icon="mdi-close"
size="small"
variant="text"
@click="close"
></v-btn>
</div>
<div class="refs-list">
<div v-for="(ref, index) in refs?.used || []" :key="index" class="ref-item" @click="openLink(ref.url)">
<div class="ref-item-icon">
<img v-if="ref.favicon" :src="ref.favicon" class="ref-item-favicon"
@error="(e) => e.target.style.display = 'none'" />
<div v-else class="ref-item-initial">{{ getRefInitial(ref.title) }}</div>
</div>
<div class="ref-item-content">
<div class="ref-item-title">{{ ref.title }}</div>
<div class="ref-item-url">{{ formatUrl(ref.url) }}</div>
<div v-if="ref.snippet" class="ref-item-snippet">{{ ref.snippet }}</div>
</div>
<v-icon size="small" class="ref-item-arrow">mdi-open-in-new</v-icon>
</div>
<div class="refs-list">
<div
v-for="(ref, index) in normalizedRefs"
:key="ref.index || index"
class="ref-item"
@click="openLink(ref.url)"
>
<div class="ref-item-icon">
<img
v-if="ref.favicon"
:src="ref.favicon"
class="ref-item-favicon"
@error="(e) => (e.target.style.display = 'none')"
/>
<div v-else class="ref-item-initial">
{{ getRefInitial(ref.title) }}
</div>
</div>
<div class="ref-item-content">
<div class="ref-item-title">{{ ref.title }}</div>
<div class="ref-item-url">{{ formatUrl(ref.url) }}</div>
<div v-if="ref.snippet" class="ref-item-snippet">
{{ ref.snippet }}
</div>
</div>
<v-icon size="small" class="ref-item-arrow">mdi-open-in-new</v-icon>
</div>
</transition>
</div>
</div>
</transition>
</template>
<script>
import { useModuleI18n } from '@/i18n/composables';
import { useModuleI18n } from "@/i18n/composables";
export default {
name: 'RefsSidebar',
props: {
modelValue: {
type: Boolean,
default: false
},
refs: {
type: Object,
default: null
}
name: "RefsSidebar",
props: {
modelValue: {
type: Boolean,
default: false,
},
emits: ['update:modelValue'],
setup() {
const { tm } = useModuleI18n('features/chat');
return { tm };
refs: {
type: Object,
default: null,
},
computed: {
isOpen: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
}
}
},
emits: ["update:modelValue"],
setup() {
const { tm } = useModuleI18n("features/chat");
return { tm };
},
computed: {
isOpen: {
get() {
return this.modelValue;
},
set(value) {
this.$emit("update:modelValue", value);
},
},
methods: {
close() {
this.isOpen = false;
},
getRefInitial(title) {
if (!title) return '?';
return title.charAt(0).toUpperCase();
},
normalizedRefs() {
const used = Array.isArray(this.refs?.used)
? this.refs.used
: Array.isArray(this.refs)
? this.refs
: [];
formatUrl(url) {
if (!url) return '';
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return url;
}
},
return used
.map((ref) => ({
index: ref?.index,
title: ref?.title || ref?.url || "Reference",
url: ref?.url,
snippet: ref?.snippet,
favicon: ref?.favicon,
}))
.filter((ref) => ref.url);
},
},
methods: {
close() {
this.isOpen = false;
},
openLink(url) {
if (url) {
window.open(url, '_blank');
}
}
}
}
getRefInitial(title) {
if (!title) return "?";
return title.charAt(0).toUpperCase();
},
formatUrl(url) {
if (!url) return "";
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return url;
}
},
openLink(url) {
if (url) {
window.open(url, "_blank");
}
},
},
};
</script>
<style scoped>
.refs-sidebar {
width: 360px;
height: 100%;
background-color: var(--v-theme-surface);
border-left: 1px solid var(--v-theme-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 360px;
height: 100%;
background-color: rgb(var(--v-theme-surface));
border-left: 1px solid rgba(var(--v-border-color), 0.16);
display: flex;
flex-direction: column;
flex-shrink: 0;
color: rgb(var(--v-theme-on-surface));
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: all 0.3s ease;
transition: all 0.3s ease;
}
.slide-left-enter-from {
transform: translateX(100%);
opacity: 0;
transform: translateX(100%);
opacity: 0;
}
.slide-left-leave-to {
transform: translateX(100%);
opacity: 0;
transform: translateX(100%);
opacity: 0;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
flex-shrink: 0;
}
.sidebar-title {
font-size: 18px;
font-weight: 600;
color: var(--v-theme-primaryText);
font-size: 18px;
font-weight: 600;
color: var(--v-theme-primaryText);
}
.refs-list {
padding: 12px;
padding-top: 0;
overflow-y: auto;
flex: 1;
padding: 12px;
padding-top: 0;
overflow-y: auto;
flex: 1;
}
.ref-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
margin-bottom: 8px;
border-radius: 8px;
border: 1px solid var(--v-theme-border);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
margin-bottom: 8px;
border-radius: 8px;
border: 1px solid var(--v-theme-border);
cursor: pointer;
transition: all 0.2s ease;
}
.ref-item:hover {
background-color: rgba(103, 58, 183, 0.05);
border-color: rgba(103, 58, 183, 0.3);
background-color: rgba(103, 58, 183, 0.05);
border-color: rgba(103, 58, 183, 0.3);
}
.ref-item-icon {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.ref-item-favicon {
width: 100%;
height: 100%;
object-fit: cover;
width: 100%;
height: 100%;
object-fit: cover;
}
.ref-item-initial {
font-size: 14px;
font-weight: 600;
color: white;
font-size: 14px;
font-weight: 600;
color: white;
}
.ref-item-content {
flex: 1;
min-width: 0;
flex: 1;
min-width: 0;
}
.ref-item-title {
font-size: 14px;
font-weight: 500;
color: var(--v-theme-primaryText);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-size: 14px;
font-weight: 500;
color: var(--v-theme-primaryText);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.ref-item-url {
font-size: 12px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ref-item-snippet {
font-size: 12px;
color: var(--v-theme-secondaryText);
opacity: 0.8;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
font-size: 12px;
color: var(--v-theme-secondaryText);
opacity: 0.8;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.ref-item-arrow {
flex-shrink: 0;
margin-top: 4px;
color: var(--v-theme-secondaryText);
opacity: 0.5;
transition: opacity 0.2s ease;
flex-shrink: 0;
margin-top: 4px;
color: var(--v-theme-secondaryText);
opacity: 0.5;
transition: opacity 0.2s ease;
}
.ref-item:hover .ref-item-arrow {
opacity: 1;
opacity: 1;
}
</style>

View File

@@ -1,290 +1,271 @@
<template>
<div class="tool-call-card" :class="{ 'is-dark': isDark, 'expanded': isExpanded }" :style="isDark ? {
backgroundColor: 'rgba(40, 60, 100, 0.4)',
borderColor: 'rgba(100, 140, 200, 0.4)'
} : {}">
<!-- Header -->
<div class="tool-call-header" :class="{ 'is-dark': isDark }" @click="toggleExpanded">
<v-icon size="small" class="tool-call-expand-icon" :class="{ 'expanded': isExpanded }">
mdi-chevron-right
</v-icon>
<v-icon size="small" class="tool-call-icon">mdi-wrench-outline</v-icon>
<div class="tool-call-info">
<span class="tool-call-name">{{ toolCall.name }}</span>
</div>
<span class="tool-call-status"
:class="{ 'status-running': !toolCall.finished_ts, 'status-finished': toolCall.finished_ts }">
<template v-if="toolCall.finished_ts">
<v-icon size="x-small" class="status-icon">mdi-check-circle</v-icon>
{{ formatDuration(toolCall.finished_ts - toolCall.ts) }}
</template>
<template v-else>
<v-icon size="x-small" class="status-icon spinning">mdi-loading</v-icon>
{{ elapsedTime }}
</template>
</span>
</div>
<div class="tool-call-card" :class="{ expanded: isExpanded }">
<button class="tool-call-header" type="button" @click="toggleExpanded">
<v-icon size="16" class="tool-call-icon">{{ toolCallIcon }}</v-icon>
<span class="tool-call-title">
{{ tm("actions.toolCallUsed", { name: displayToolName }) }}
</span>
<span class="tool-call-duration">{{ toolCallDuration }}</span>
<v-icon
size="22"
class="tool-call-expand-icon"
:class="{ expanded: isExpanded }"
>
mdi-chevron-right
</v-icon>
</button>
<!-- Details -->
<div v-if="isExpanded" class="tool-call-details" :style="isDark ? {
borderTopColor: 'rgba(100, 140, 200, 0.3)',
backgroundColor: 'rgba(30, 45, 70, 0.5)'
} : {}">
<!-- ID -->
<div class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value" :style="isDark ? { backgroundColor: 'transparent' } : {}">
{{ toolCall.id }}
<div v-if="isExpanded" class="tool-call-details">
<div v-if="toolCall.id" class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value">
{{ toolCall.id }}
</code>
</div>
</div>
<!-- Args -->
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json" :style="isDark ? { backgroundColor: 'transparent' } : {}">{{
JSON.stringify(toolCall.args, null, 2) }}</pre>
</div>
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json">{{
JSON.stringify(toolCall.args, null, 2)
}}</pre>
</div>
<!-- Result -->
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre class="detail-value detail-json detail-result"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{
formattedResult }}</pre>
</div>
</div>
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre class="detail-value detail-json detail-result">{{
formattedResult
}}</pre>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { computed, onMounted, onUnmounted, ref } from "vue";
import { useModuleI18n } from "@/i18n/composables";
const props = defineProps({
toolCall: {
type: Object,
required: true
},
isDark: {
type: Boolean,
default: false
},
initialExpanded: {
type: Boolean,
default: false
}
toolCall: {
type: Object,
required: true,
},
isDark: {
type: Boolean,
default: false,
},
initialExpanded: {
type: Boolean,
default: false,
},
});
const { tm } = useModuleI18n("features/chat");
const isExpanded = ref(props.initialExpanded);
const currentTime = ref(Date.now() / 1000);
let timer = null;
const elapsedTime = computed(() => {
if (props.toolCall.finished_ts) return '';
const elapsed = currentTime.value - props.toolCall.ts;
return formatDuration(elapsed);
if (props.toolCall.finished_ts) return "";
const startTime = Number(props.toolCall.ts);
if (!Number.isFinite(startTime) || startTime <= 0) return "";
return formatDuration(currentTime.value - startTime);
});
const displayToolName = computed(() => props.toolCall.name || "tool");
const toolCallIcon = computed(() => {
const name = String(props.toolCall.name || "");
if (name === "astrbot_execute_ipython" || name === "astrbot_execute_python") {
return "mdi-code-json";
}
if (name.includes("web_search") || name.includes("tavily")) {
return "mdi-web";
}
if (name === "astrbot_execute_shell") {
return "mdi-console-line";
}
return "mdi-wrench";
});
const toolCallDuration = computed(() => {
const startTime = Number(props.toolCall.ts);
if (!Number.isFinite(startTime) || startTime <= 0) return "";
if (props.toolCall.finished_ts) {
return formatDuration(Number(props.toolCall.finished_ts) - startTime);
}
return elapsedTime.value;
});
const formattedResult = computed(() => {
if (!props.toolCall.result) return '';
try {
const parsed = JSON.parse(props.toolCall.result);
return JSON.stringify(parsed, null, 2);
} catch {
return props.toolCall.result;
}
if (!props.toolCall.result) return "";
try {
const parsed = JSON.parse(props.toolCall.result);
return JSON.stringify(parsed, null, 2);
} catch {
return props.toolCall.result;
}
});
const formatDuration = (seconds) => {
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`;
} else if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
} else {
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
}
if (!Number.isFinite(seconds) || seconds < 0) return "";
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`;
} else if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
} else {
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
}
};
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
isExpanded.value = !isExpanded.value;
};
const updateTime = () => {
currentTime.value = Date.now() / 1000;
currentTime.value = Date.now() / 1000;
};
onMounted(() => {
// Update time periodically if tool call is running
if (!props.toolCall.finished_ts) {
timer = setInterval(updateTime, 100);
}
if (!props.toolCall.finished_ts) {
timer = setInterval(updateTime, 100);
}
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
if (timer) {
clearInterval(timer);
}
});
</script>
<style scoped>
.tool-call-card {
border-radius: 8px;
overflow: hidden;
background-color: #eff3f6;
margin: 8px 0px;
width: fit-content;
min-width: 320px;
max-width: 100%;
transition: all 0.1s ease;
margin: 6px 0;
max-width: 100%;
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: inherit;
line-height: inherit;
}
.tool-call-card.expanded {
width: 100%;
width: 100%;
}
.tool-call-header {
display: flex;
align-items: center;
padding: 10px 12px;
cursor: pointer;
user-select: none;
transition: background-color;
gap: 8px;
max-width: 100%;
border: 0;
padding: 0;
background: transparent;
color: inherit;
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
gap: 8px;
font: inherit;
text-align: left;
}
.tool-call-header:hover {
background-color: rgba(169, 194, 219, 0.15);
}
.tool-call-header.is-dark:hover {
background-color: rgba(100, 150, 200, 0.2);
color: rgba(var(--v-theme-on-surface), 0.88);
}
.tool-call-expand-icon {
color: var(--v-theme-secondary);
transition: transform 0.2s ease;
flex-shrink: 0;
color: currentcolor;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.tool-call-expand-icon.expanded {
transform: rotate(90deg);
transform: rotate(90deg);
}
.tool-call-icon {
color: var(--v-theme-secondary);
flex-shrink: 0;
color: currentcolor;
flex-shrink: 0;
}
.tool-call-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
.tool-call-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-call-name {
font-size: 13px;
font-weight: 600;
color: var(--v-theme-secondary);
}
.tool-call-status {
margin-left: 8px;
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
}
.tool-call-status.status-running {
color: #ff9800;
}
.tool-call-status.status-finished {
color: #4caf50;
}
.tool-call-status .status-icon {
font-size: 14px;
}
.tool-call-status .status-icon.spinning {
animation: spin 1s linear infinite;
.tool-call-duration {
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.48);
}
.tool-call-details {
padding: 12px;
background-color: rgba(255, 255, 255, 0.5);
animation: fadeIn 0.2s ease-in-out;
margin-top: 8px;
padding-left: 26px;
animation: fadeIn 0.2s ease-in-out;
}
.tool-call-detail-row {
display: flex;
flex-direction: column;
margin-bottom: 8px;
display: flex;
flex-direction: column;
margin-bottom: 8px;
}
.tool-call-detail-row:last-child {
margin-bottom: 0;
margin-bottom: 0;
}
.detail-label {
font-size: 11px;
font-weight: 600;
color: var(--v-theme-secondaryText);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
font-size: 11px;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), 0.55);
text-transform: uppercase;
margin-bottom: 4px;
}
.detail-value {
font-size: 12px;
color: var(--v-theme-primaryText);
background-color: transparent;
padding: 4px 8px;
border-radius: 4px;
word-break: break-all;
font-size: 12px;
color: rgba(var(--v-theme-on-surface), 0.8);
background-color: transparent;
padding: 0;
border-radius: 4px;
word-break: break-all;
}
.detail-json {
font-family: 'Fira Code', 'Consolas', monospace;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
margin: 0;
}
.detail-result {
max-height: 300px;
background-color: transparent;
max-height: 300px;
background-color: transparent;
}
.animate-fade-in {
animation: fadeIn 0.2s ease-in-out;
animation: fadeIn 0.2s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
from {
opacity: 0;
}
to {
opacity: 1;
}
to {
opacity: 1;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -110,6 +110,10 @@
<small style="color: grey">*{{ tm('dialogs.addServer.tips.timeoutConfig') }}</small>
<v-alert type="info" variant="tonal" density="compact" class="mt-3">
{{ tm('dialogs.addServer.tips.transportRecommendation') }}
</v-alert>
<div class="monaco-container" style="margin-top: 16px;">
<VueMonacoEditor v-model:value="serverConfigJson" theme="vs-dark" language="json" :options="{
minimap: {

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import type { ToolItem } from '../types';
import type { BuiltinToolConfigTag, ToolConfigCondition, ToolItem } from '../types';
const { tm: tmTool } = useModuleI18n('features/tooluse');
@@ -15,7 +15,7 @@ const emit = defineEmits<{
}>();
const toolHeaders = computed(() => [
{ title: tmTool('functionTools.title'), key: 'name', minWidth: '240px' },
{ title: tmTool('functionTools.title'), key: 'name', minWidth: '320px' },
{ title: tmTool('functionTools.description'), key: 'description' },
{ title: tmTool('functionTools.table.origin'), key: 'origin', sortable: false, width: '120px' },
{ title: tmTool('functionTools.table.originName'), key: 'origin_name', sortable: false, width: '160px' },
@@ -23,6 +23,52 @@ const toolHeaders = computed(() => [
]);
const parameterEntries = (tool: ToolItem) => Object.entries(tool.parameters?.properties || {});
const formatConfigValue = (value: unknown) => {
if (Array.isArray(value)) {
return value.map(item => String(item)).join(', ');
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
if (value === null || value === undefined || value === '') {
return '-';
}
return String(value);
};
const formatCondition = (condition: ToolConfigCondition) => {
if (condition.message) {
return condition.message;
}
switch (condition.operator) {
case 'truthy':
return tmTool('functionTools.configTags.conditions.truthy', {
key: condition.key
});
case 'equals':
return tmTool('functionTools.configTags.conditions.equals', {
key: condition.key,
expected: formatConfigValue(condition.expected)
});
case 'in':
return tmTool('functionTools.configTags.conditions.in', {
key: condition.key,
expected: formatConfigValue(condition.expected)
});
default:
return tmTool('functionTools.configTags.conditions.fallback', {
key: condition.key,
actual: formatConfigValue(condition.actual)
});
}
};
const enabledConfigTags = (tool: ToolItem): BuiltinToolConfigTag[] => {
if (tool.origin !== 'builtin') return [];
return (tool.builtin_config_tags || []).filter(tag => tag.enabled);
};
</script>
<template>
@@ -38,7 +84,39 @@ const parameterEntries = (tool: ToolItem) => Object.entries(tool.parameters?.pro
>
<template #item.name="{ item }">
<div class="py-2">
<div class="tool-name text-body-2 font-weight-medium">{{ item.name }}</div>
<div class="d-flex flex-wrap align-center ga-1">
<div class="tool-name text-body-2 font-weight-medium">{{ item.name }}</div>
<v-tooltip
v-for="tag in enabledConfigTags(item)"
:key="`${item.name}-${tag.conf_id}`"
location="top"
>
<template #activator="{ props: tooltipProps }">
<v-chip
v-bind="tooltipProps"
size="x-small"
variant="tonal"
color="secondary"
class="text-caption font-weight-medium"
>
{{ tag.conf_name }}
</v-chip>
</template>
<div class="tool-config-tooltip">
<div class="text-body-2 font-weight-medium mb-2">
{{ tmTool('functionTools.configTags.tooltipTitle', { config: tag.conf_name }) }}
</div>
<div
v-for="(condition, index) in tag.matched_conditions"
:key="`${tag.conf_id}-${index}-${condition.key}`"
class="text-body-2 text-medium-emphasis mb-1"
>
{{ formatCondition(condition) }}
</div>
</div>
</v-tooltip>
</div>
</div>
</template>
@@ -135,4 +213,16 @@ const parameterEntries = (tool: ToolItem) => Object.entries(tool.parameters?.pro
font-size: 0.9rem;
line-height: 1.35;
}
.tool-config-tooltip {
max-width: 360px;
padding: 4px 0;
color: rgba(255, 255, 255, 0.92);
}
.tool-config-tooltip :deep(.text-body-2),
.tool-config-tooltip :deep(.text-medium-emphasis),
.tool-config-tooltip :deep(.font-weight-medium) {
color: inherit !important;
}
</style>

View File

@@ -89,6 +89,23 @@ export interface ToolParameter {
description?: string;
}
export interface ToolConfigCondition {
key: string;
operator: 'truthy' | 'equals' | 'in' | 'custom' | string;
expected?: unknown;
actual?: unknown;
matched: boolean;
message?: string | null;
}
export interface BuiltinToolConfigTag {
conf_id: string;
conf_name: string;
enabled: boolean;
matched_conditions: ToolConfigCondition[];
failed_conditions: ToolConfigCondition[];
}
/** MCP/函数工具对象 */
export interface ToolItem {
name: string;
@@ -100,4 +117,6 @@ export interface ToolItem {
};
origin?: string;
origin_name?: string;
builtin_config_statuses?: BuiltinToolConfigTag[];
builtin_config_tags?: BuiltinToolConfigTag[];
}

View File

@@ -90,31 +90,52 @@
<div v-if="filteredTools.length > 0" class="tools-selection">
<v-virtual-scroll :items="filteredTools" height="300" item-height="72">
<template v-slot:default="{ item }">
<v-list-item :key="item.name" density="comfortable"
@click="toggleTool(item.name)">
<template v-slot:prepend>
<v-checkbox-btn :model-value="isToolSelected(item.name)"
@click.stop="toggleTool(item.name)" />
<v-tooltip
:disabled="!isBuiltinTool(item)"
location="top"
>
<template v-slot:activator="{ props: tooltipProps }">
<div v-bind="tooltipProps">
<v-list-item
:key="item.name"
density="comfortable"
:disabled="isBuiltinTool(item)"
@click="toggleTool(item.name)"
>
<template v-slot:prepend>
<v-checkbox-btn
v-if="!isBuiltinTool(item)"
:model-value="isToolSelected(item.name)"
@click.stop="toggleTool(item.name)"
/>
<div
v-else
class="builtin-tool-checkbox-placeholder"
/>
</template>
<v-list-item-title>
{{ item.name }}
<v-chip v-if="item.origin" size="x-small" color="info" class="mr-2"
variant="tonal">
{{ item.origin }}
</v-chip>
<v-chip v-if="item.origin_name" size="x-small" color="info"
variant="outlined">
{{ item.origin_name }}
</v-chip>
</v-list-item-title>
<v-list-item-subtitle v-if="item.description">
{{ truncateText(item.description, 100) }}
</v-list-item-subtitle>
</v-list-item>
</div>
</template>
<v-list-item-title>
{{ item.name }}
<v-chip v-if="item.origin" size="x-small" color="info" class="mr-2"
variant="tonal">
{{ item.origin }}
</v-chip>
<v-chip v-if="item.origin_name" size="x-small" color="info"
variant="outlined">
{{ item.origin_name }}
</v-chip>
</v-list-item-title>
<v-list-item-subtitle v-if="item.description">
{{ truncateText(item.description, 100) }}
</v-list-item-subtitle>
</v-list-item>
<span>{{ tm('form.builtinToolDisabledHint') }}</span>
</v-tooltip>
</template>
</v-virtual-scroll>
</div>
@@ -155,11 +176,26 @@
</h4>
<div v-if="Array.isArray(personaForm.tools) && personaForm.tools.length > 0"
class="d-flex flex-wrap ga-1" style="max-height: 100px; overflow-y: auto;">
<v-chip v-for="toolName in personaForm.tools" :key="toolName" size="small"
color="primary" variant="tonal" closable
@click:close="removeTool(toolName)">
{{ toolName }}
</v-chip>
<v-tooltip
v-for="toolName in personaForm.tools"
:key="toolName"
:disabled="!isBuiltinToolName(toolName)"
location="top"
>
<template v-slot:activator="{ props: tooltipProps }">
<v-chip
v-bind="tooltipProps"
size="small"
color="primary"
variant="tonal"
:closable="!isBuiltinToolName(toolName)"
@click:close="removeTool(toolName)"
>
{{ toolName }}
</v-chip>
</template>
<span>{{ tm('form.builtinToolDisabledHint') }}</span>
</v-tooltip>
</div>
<div v-else class="text-body-2 text-medium-emphasis">
{{ tm('form.noToolsSelected') }}
@@ -712,6 +748,9 @@ export default {
},
toggleTool(toolName) {
if (this.isBuiltinToolName(toolName)) {
return;
}
// 如果当前是全选状态,需要先转换为具体的工具列表
if (this.personaForm.tools === null) {
// 如果是全选状态,点击某个工具表示要取消选择该工具
@@ -735,6 +774,9 @@ export default {
},
removeTool(toolName) {
if (this.isBuiltinToolName(toolName)) {
return;
}
// 如果当前是全选状态,需要先转换为具体的工具列表
if (this.personaForm.tools === null) {
// 创建一个包含所有工具的数组,然后移除指定工具
@@ -784,6 +826,14 @@ export default {
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
},
isBuiltinTool(tool) {
return tool?.origin === 'builtin' || tool?.readonly === true;
},
isBuiltinToolName(toolName) {
return this.availableTools.some(tool => tool.name === toolName && this.isBuiltinTool(tool));
},
getDialogRules(index) {
const dialogType = index % 2 === 0 ? this.tm('form.userMessage') : this.tm('form.assistantMessage');
return [
@@ -859,6 +909,12 @@ export default {
overflow-y: auto;
}
.builtin-tool-checkbox-placeholder {
width: 40px;
height: 40px;
flex: 0 0 40px;
}
.skills-selection {
max-height: 300px;
overflow-y: auto;

View File

@@ -117,7 +117,9 @@ const defaultPersonaData = {
const normalizedTools = computed(() => (Array.isArray(personaData.value?.tools) ? personaData.value.tools : []))
const normalizedSkills = computed(() => (Array.isArray(personaData.value?.skills) ? personaData.value.skills : []))
const allToolsCount = computed(() => Object.keys(toolMetaMap.value).length)
const allToolsCount = computed(() =>
Object.values(toolMetaMap.value).filter((tool) => tool.origin !== 'builtin').length
)
const allSkillsCount = computed(() => availableSkills.value.length)
const resolvedTools = computed(() =>
normalizedTools.value.map((toolName) => {

File diff suppressed because it is too large Load Diff

View File

@@ -56,6 +56,10 @@
"ipython": {
"output": "Output"
},
"toolStatus": {
"done": "Done",
"running": "Running"
},
"conversation": {
"newConversation": "New Conversation",
"noHistory": "No conversation history",
@@ -158,4 +162,4 @@
"partialFailure": "{failed} of {total} conversations failed to delete",
"requestFailed": "Failed to delete conversations. Please try again."
}
}
}

View File

@@ -152,11 +152,11 @@
},
"agent_computer_use": {
"description": "Agent Computer Use",
"hint": "Allows the AstrBot to access and use your computer or an sandbox environment to perform more complex tasks. See [Sandbox Mode](https://docs.astrbot.app/use/astrbot-agent-sandbox.html), [Skills](https://docs.astrbot.app/use/skills.html)",
"hint": "Allows the AstrBot to access and use your computer or an sandbox environment to perform more complex tasks. See: [Computer Use](https://docs.astrbot.app/use/computer.html).",
"provider_settings": {
"computer_use_runtime": {
"description": "Computer Use Runtime",
"hint": "sandbox means running in a sandbox environment, local means running in a local environment, none means disabling Computer Use. If skills are uploaded, choosing none will cause them to not be usable by the Agent."
"hint": "Environment allowed for Agent usage. `local` means the local machine environment, `sandbox` means the sandbox environment, and `none` means no environment is allowed."
},
"computer_use_require_admin": {
"description": "Require AstrBot Admin Permission",
@@ -390,6 +390,10 @@
"description": "Discord Activity Name",
"hint": "Optional Discord activity name. Leave empty to disable."
},
"discord_allow_bot_messages": {
"description": "Allow Bot Messages",
"hint": "When enabled, AstrBot will receive messages from other Discord bots. Useful for bot-to-bot communication scenarios (e.g., message forwarding). Disabled by default."
},
"discord_command_register": {
"description": "Register Discord slash commands",
"hint": "When enabled, AstrBot will automatically register plugin commands as Discord slash commands"
@@ -1637,4 +1641,4 @@
"helpMiddle": "or",
"helpSuffix": "."
}
}
}

View File

@@ -14,9 +14,11 @@
},
"actions": {
"create": "New Task",
"edit": "Edit",
"refresh": "Refresh",
"delete": "Delete",
"cancel": "Cancel",
"save": "Save",
"submit": "Create"
},
"overview": {
@@ -77,6 +79,7 @@
},
"form": {
"title": "New Task",
"editTitle": "Edit Task",
"chatHint": "You can ask AstrBot in chat to create future tasks instead of adding them here.",
"runOnce": "One-off task",
"name": "Task name",
@@ -90,6 +93,7 @@
},
"messages": {
"loadFailed": "Failed to load tasks",
"updateSuccess": "Updated successfully",
"updateFailed": "Failed to update",
"deleteSuccess": "Deleted",
"deleteFailed": "Failed to delete",

View File

@@ -35,6 +35,7 @@
"mcpServersQuickSelect": "MCP Servers Quick Select",
"searchTools": "Search Tools",
"selectedTools": "Selected Tools",
"builtinToolDisabledHint": "Builtin tools cannot be enabled or disabled here yet. Please enable or disable the corresponding config items in the config file.",
"noToolsAvailable": "No tools available",
"noToolsFound": "No matching tools found",
"loadingTools": "Loading tools...",

View File

@@ -47,6 +47,15 @@
"originName": "Origin Name",
"readonly": "Read-only",
"actions": "Actions"
},
"configTags": {
"tooltipTitle": "This tool is enabled in config file {config} because:",
"conditions": {
"truthy": "{key} is enabled",
"equals": "{key} = {expected}",
"in": "{key} matched {expected}",
"fallback": "{key} is currently {actual}"
}
}
},
"marketplace": {
@@ -86,7 +95,8 @@
"sync": "Sync"
},
"tips": {
"timeoutConfig": "Please configure tool call timeout separately in the configuration page"
"timeoutConfig": "Please configure tool call timeout separately in the configuration page",
"transportRecommendation": "Prefer Streamable HTTP or SSE mode. Stdio mode starts a local process on the AstrBot host and should only be used for trusted MCP servers."
}
},
"serverDetail": {

View File

@@ -12,11 +12,21 @@
"onboard": {
"title": "Quick Onboarding",
"subtitle": "Complete initialization directly on the welcome page.",
"step1Title": "Configure Platform Bot",
"step1Desc": "Connect AstrBot to IM platforms like QQ, Lark, Slack, Telegram, etc.",
"step2Title": "Configure AI Model",
"step2Desc": "Configure AI models for AstrBot.",
"step1Title": "Configure AI Model",
"step1Desc": "Configure AI models for AstrBot.",
"step2Title": "Configure Platform Bot",
"step2Desc": "Connect AstrBot to IM platforms like QQ, Lark, Slack, Telegram, etc.",
"step3Title": "Allow Agent to Use the Computer",
"step3Desc": "Set whether the Agent can access and use this computer.",
"step3HelpTitle": "Access Details",
"step3HelpItem1": "The Agent can access and use the workspace directory, which is isolated for each user, as well as Skills to handle more complex tasks.",
"step3HelpItem2": "For AstrBot administrators, the Agent is additionally allowed to execute shell commands, run Python code, and access all local directories on this machine.",
"step3HelpClose": "Close",
"step3SelectLabel": "Computer Access",
"step3Allow": "Allow",
"step3Deny": "Disallow",
"configure": "Configure",
"save": "Save",
"skip": "Skip",
"pending": "Pending",
"completed": "Completed",
@@ -24,7 +34,11 @@
"platformLoadFailed": "Failed to load platform configuration",
"providerLoadFailed": "Failed to load provider configuration",
"providerUpdateFailed": "Failed to update default chat provider in config file \"default\"",
"providerDefaultUpdated": "Default chat provider in config file \"default\" has been set to {id}"
"providerDefaultUpdated": "Default chat provider in config file \"default\" has been set to {id}",
"computerAccessUpdateFailed": "Failed to update Agent computer access",
"computerAccessAllowed": "Agent is now allowed to access and use the computer",
"computerAccessDenied": "Agent is no longer allowed to access and use the computer",
"step3HelpItem3": "For more detailed control, configure the Computer Access options under Settings -> General -> Computer Access."
},
"resources": {
"title": "Resources",

View File

@@ -1,151 +1,155 @@
{
"title": "Давай пообщаемся!",
"subtitle": "Общение с AI-помощником",
"input": {
"placeholder": "Введите сообщение...",
"send": "Отправить",
"clear": "Очистить",
"upload": "Загрузить файл",
"voice": "Голосовой ввод",
"recordingPrompt": "Запись... говорите",
"chatPrompt": "Давай пообщаемся!",
"dropToUpload": "Отпустите, чтобы загрузить файл",
"stopGenerating": "Остановить генерацию"
},
"message": {
"user": "Вы",
"assistant": "Ассистент",
"system": "Система",
"error": "Ошибка в сообщении",
"loading": "Думаю..."
},
"voice": {
"start": "Начать запись",
"stop": "Стоп",
"recording": "Запись",
"processing": "Обработка...",
"error": "Ошибка записи",
"listening": "Слушаю...",
"speaking": "Говорю",
"startRecording": "Начать голосовой ввод",
"liveMode": "Общение в реальном времени"
},
"welcome": {
"title": "Добро пожаловать в AstrBot",
"subtitle": "Ваш умный помощник",
"quickActions": "Быстрые действия",
"examples": "Примеры вопросов"
},
"actions": {
"copy": "Копировать",
"regenerate": "Перегенерировать",
"like": "Нравится",
"dislike": "Не нравится",
"share": "Поделиться",
"newChat": "Новый чат",
"deleteChat": "Удалить чат",
"editTitle": "Изменить заголовок",
"fullscreen": "На весь экран",
"exitFullscreen": "Выход из полноэкранного режима",
"reply": "Ответить",
"providerConfig": "Настройки AI",
"toolsUsed": "Использованные инструменты",
"toolCallUsed": "Использован инструмент {name}",
"pythonCodeAnalysis": "Использован анализ кода Python"
},
"ipython": {
"output": "Вывод"
},
"conversation": {
"newConversation": "Новый чат",
"noHistory": "История диалогов пуста",
"systemStatus": "Статус системы",
"llmService": "Сервис LLM",
"speechToText": "Преобразование речи",
"editDisplayName": "Изменить имя чата",
"displayName": "Имя чата",
"displayNameUpdated": "Имя чата обновлено",
"displayNameUpdateFailed": "Не удалось обновить имя чата",
"confirmDelete": "Вы уверены, что хотите удалить «{name}»? Это действие необратимо."
},
"modes": {
"darkMode": "Темная тема",
"lightMode": "Светлая тема"
},
"shortcuts": {
"help": "Справка",
"voiceRecord": "Запись голоса",
"pasteImage": "Вставить изображение",
"sendKey": {
"title": "Клавиша отправки",
"enterToSend": "Enter для отправки",
"shiftEnterToSend": "Shift+Enter для отправки"
}
},
"streaming": {
"enabled": "Потоковый ответ включен",
"disabled": "Потоковый ответ выключен",
"on": "Поток",
"off": "Обычный"
},
"transport": {
"title": "Протокол передачи",
"sse": "SSE",
"websocket": "WebSocket"
},
"config": {
"title": "Конфигурация"
},
"reasoning": {
"thinking": "Рассуждение"
},
"reply": {
"replyTo": "В ответ на",
"notFound": "Сообщение не найдено"
},
"project": {
"title": "Проект",
"create": "Создать проект",
"edit": "Изменить проект",
"name": "Имя проекта",
"emoji": "Иконка (Emoji)",
"description": "Описание проекта (опционально)",
"noSessions": "В этом проекте пока нет диалогов",
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
},
"time": {
"today": "Сегодня",
"yesterday": "Вчера"
},
"stats": {
"tokens": "Токены",
"inputTokens": "Входящие",
"outputTokens": "Исходящие",
"cachedTokens": "Кэшированные",
"duration": "Время",
"ttft": "Время до первого токена"
},
"refs": {
"title": "Ссылки",
"sources": "Источники"
},
"connection": {
"title": "Статус подключения",
"message": "Системе необходимо переустановить соединение с чатом.",
"reasons": "Это может быть вызвано следующими причинами:",
"reasonWindowResize": "Изменение размера окна (нормально)",
"reasonMultipleTabs": "Страница чата открыта в другой вкладке",
"reasonNetworkIssue": "Временная проблема с сетью",
"notice": "Примечание: для стабильной работы допускается только одно активное соединение. Если вы используете чат в нескольких вкладках, рекомендуем оставить только одну.",
"understand": "Понятно",
"status": {
"reconnecting": "Переподключение...",
"reconnected": "Соединение восстановлено",
"failed": "Ошибка подключения, обновите страницу"
}
},
"errors": {
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
"title": "Давай пообщаемся!",
"subtitle": "Общение с AI-помощником",
"input": {
"placeholder": "Введите сообщение...",
"send": "Отправить",
"clear": "Очистить",
"upload": "Загрузить файл",
"voice": "Голосовой ввод",
"recordingPrompt": "Запись... говорите",
"chatPrompt": "Давай пообщаемся!",
"dropToUpload": "Отпустите, чтобы загрузить файл",
"stopGenerating": "Остановить генерацию"
},
"message": {
"user": "Вы",
"assistant": "Ассистент",
"system": "Система",
"error": "Ошибка в сообщении",
"loading": "Думаю..."
},
"voice": {
"start": "Начать запись",
"stop": "Стоп",
"recording": "Запись",
"processing": "Обработка...",
"error": "Ошибка записи",
"listening": "Слушаю...",
"speaking": "Говорю",
"startRecording": "Начать голосовой ввод",
"liveMode": "Общение в реальном времени"
},
"welcome": {
"title": "Добро пожаловать в AstrBot",
"subtitle": "Ваш умный помощник",
"quickActions": "Быстрые действия",
"examples": "Примеры вопросов"
},
"actions": {
"copy": "Копировать",
"regenerate": "Перегенерировать",
"like": "Нравится",
"dislike": "Не нравится",
"share": "Поделиться",
"newChat": "Новый чат",
"deleteChat": "Удалить чат",
"editTitle": "Изменить заголовок",
"fullscreen": "На весь экран",
"exitFullscreen": "Выход из полноэкранного режима",
"reply": "Ответить",
"providerConfig": "Настройки AI",
"toolsUsed": "Использованные инструменты",
"toolCallUsed": "Использован инструмент {name}",
"pythonCodeAnalysis": "Использован анализ кода Python"
},
"ipython": {
"output": "Вывод"
},
"toolStatus": {
"done": "Готово",
"running": "Выполняется"
},
"conversation": {
"newConversation": "Новый чат",
"noHistory": "История диалогов пуста",
"systemStatus": "Статус системы",
"llmService": "Сервис LLM",
"speechToText": "Преобразование речи",
"editDisplayName": "Изменить имя чата",
"displayName": "Имя чата",
"displayNameUpdated": "Имя чата обновлено",
"displayNameUpdateFailed": "Не удалось обновить имя чата",
"confirmDelete": "Вы уверены, что хотите удалить «{name}»? Это действие необратимо."
},
"modes": {
"darkMode": "Темная тема",
"lightMode": "Светлая тема"
},
"shortcuts": {
"help": "Справка",
"voiceRecord": "Запись голоса",
"pasteImage": "Вставить изображение",
"sendKey": {
"title": "Клавиша отправки",
"enterToSend": "Enter для отправки",
"shiftEnterToSend": "Shift+Enter для отправки"
}
},
"streaming": {
"enabled": "Потоковый ответ включен",
"disabled": "Потоковый ответ выключен",
"on": "Поток",
"off": "Обычный"
},
"transport": {
"title": "Протокол передачи",
"sse": "SSE",
"websocket": "WebSocket"
},
"config": {
"title": "Конфигурация"
},
"reasoning": {
"thinking": "Рассуждение"
},
"reply": {
"replyTo": "В ответ на",
"notFound": "Сообщение не найдено"
},
"project": {
"title": "Проект",
"create": "Создать проект",
"edit": "Изменить проект",
"name": "Имя проекта",
"emoji": "Иконка (Emoji)",
"description": "Описание проекта (опционально)",
"noSessions": "В этом проекте пока нет диалогов",
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
},
"time": {
"today": "Сегодня",
"yesterday": "Вчера"
},
"stats": {
"tokens": "Токены",
"inputTokens": "Входящие",
"outputTokens": "Исходящие",
"cachedTokens": "Кэшированные",
"duration": "Время",
"ttft": "Время до первого токена"
},
"refs": {
"title": "Ссылки",
"sources": "Источники"
},
"connection": {
"title": "Статус подключения",
"message": "Системе необходимо переустановить соединение с чатом.",
"reasons": "Это может быть вызвано следующими причинами:",
"reasonWindowResize": "Изменение размера окна (нормально)",
"reasonMultipleTabs": "Страница чата открыта в другой вкладке",
"reasonNetworkIssue": "Временная проблема с сетью",
"notice": "Примечание: для стабильной работы допускается только одно активное соединение. Если вы используете чат в нескольких вкладках, рекомендуем оставить только одну.",
"understand": "Понятно",
"status": {
"reconnecting": "Переподключение...",
"reconnected": "Соединение восстановлено",
"failed": "Ошибка подключения, обновите страницу"
}
},
"errors": {
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
}
}

View File

@@ -152,11 +152,11 @@
},
"agent_computer_use": {
"description": "Использование компьютера (Agent Computer Use)",
"hint": "Позволяет AstrBot получать доступ к вашему компьютеру или песочнице для выполнения сложных задач. См. [Режим песочницы](https://docs.astrbot.app/use/astrbot-agent-sandbox.html), [Навыки](https://docs.astrbot.app/use/skills.html)",
"hint": "Позволяет AstrBot получать доступ к вашему компьютеру или песочнице для выполнения сложных задач. Подробнее: [Использование компьютера](https://docs.astrbot.app/use/computer.html).",
"provider_settings": {
"computer_use_runtime": {
"description": "Среда выполнения (Runtime)",
"hint": "'sandbox' означает запуск в изолированной среде, 'local' — локально на вашем ПК, 'none' — отключено. Если навыки загружены, выбор 'none' сделает их недоступными для агента."
"hint": "Среда, к которой разрешён доступ Agent: `local` — локальная среда компьютера, `sandbox` — изолированная песочница, `none` — запретить доступ к любым средам."
},
"computer_use_require_admin": {
"description": "Требовать права администратора AstrBot",
@@ -390,6 +390,10 @@
"description": "Название активности Discord",
"hint": "Статус бота в Discord. Оставьте пустым для отключения."
},
"discord_allow_bot_messages": {
"description": "Разрешить сообщения от ботов",
"hint": "Если включено, AstrBot будет получать сообщения от других Discord-ботов. Полезно для взаимодействия между ботами (например, пересылка сообщений). По умолчанию отключено."
},
"discord_command_register": {
"description": "Регистрировать слэш-команды Discord",
"hint": "Если включено, команды плагинов будут зарегистрированы как /команды Discord."

View File

@@ -14,9 +14,11 @@
},
"actions": {
"create": "Новая задача",
"edit": "Изменить",
"refresh": "Обновить",
"delete": "Удалить",
"cancel": "Отмена",
"save": "Сохранить",
"submit": "Создать"
},
"overview": {
@@ -77,6 +79,7 @@
},
"form": {
"title": "Создать задачу",
"editTitle": "Редактировать задачу",
"chatHint": "Вы можете ставить задачи прямо в чате, AstrBot создаст их автоматически без заполнения этой формы.",
"runOnce": "Разовая задача",
"name": "Имя задачи",
@@ -90,6 +93,7 @@
},
"messages": {
"loadFailed": "Ошибка загрузки задач",
"updateSuccess": "Задача обновлена",
"updateFailed": "Ошибка обновления",
"deleteSuccess": "Удалено",
"deleteFailed": "Ошибка удаления",

View File

@@ -35,6 +35,7 @@
"mcpServersQuickSelect": "Быстрый выбор MCP серверов",
"searchTools": "Поиск инструментов",
"selectedTools": "Выбранные инструменты",
"builtinToolDisabledHint": "Встроенные инструменты пока нельзя включать или выключать здесь. Измените соответствующие параметры в файле конфигурации.",
"noToolsAvailable": "Нет доступных инструментов",
"noToolsFound": "Инструменты не найдены",
"loadingTools": "Загрузка инструментов...",
@@ -143,4 +144,4 @@
"success": "Объект перемещен",
"error": "Ошибка перемещения"
}
}
}

View File

@@ -47,6 +47,15 @@
"originName": "Имя источника",
"readonly": "Только чтение",
"actions": "Действия"
},
"configTags": {
"tooltipTitle": "Этот инструмент включен в файле конфигурации {config}, потому что:",
"conditions": {
"truthy": "{key} включен",
"equals": "{key} = {expected}",
"in": "{key} соответствует {expected}",
"fallback": "Текущее значение {key}: {actual}"
}
}
},
"marketplace": {
@@ -86,7 +95,8 @@
"sync": "Синхронизировать"
},
"tips": {
"timeoutConfig": "Тайм-аут вызова инструментов настраивается отдельно на странице конфигурации"
"timeoutConfig": "Тайм-аут вызова инструментов настраивается отдельно на странице конфигурации",
"transportRecommendation": "Рекомендуется сначала использовать режим Streamable HTTP или SSE. Режим stdio запускает локальный процесс на хосте AstrBot и подходит только для доверенных MCP-серверов."
}
},
"serverDetail": {

View File

@@ -1,37 +1,51 @@
{
"greeting": {
"morning": "Доброе утро, добро пожаловать в AstrBot",
"afternoon": "Добрый день, добро пожаловать в AstrBot",
"evening": "Добрый вечер, добро пожаловать в AstrBot",
"newYear": "С Новым Годом!"
},
"subtitle": "Сначала пройдите базовое руководство. Настройку платформ и провайдеров моделей можно завершить позже.",
"announcement": {
"title": "Объявление"
},
"onboard": {
"title": "Быстрый старт",
"subtitle": "Вы можете выполнить первичную настройку прямо здесь.",
"step1Title": "Настройка платформ",
"step1Desc": "Подключите AstrBot к QQ, Lark, WeChat, Telegram и другим мессенджерам.",
"step2Title": "Настройка AI моделей",
"step2Desc": "Выберите и настройте AI провайдеров для AstrBot.",
"configure": "Настроить",
"skip": "Пропустить",
"pending": "Ожидает",
"completed": "Готово",
"skipped": "Пропущено",
"platformLoadFailed": "Ошибка загрузки конфигурации платформ",
"providerLoadFailed": "Ошибка загрузки конфигурации провайдеров",
"providerUpdateFailed": "Ошибка обновления провайдера по умолчанию в файле default",
"providerDefaultUpdated": "Провайдер {id} установлен по умолчанию в файле default"
},
"resources": {
"title": "Ресурсы",
"githubDesc": "Поставьте нам звезду на GitHub!",
"docsTitle": "Документация",
"docsDesc": "Официальная документация AstrBot.",
"afdianTitle": "Afdian",
"afdianDesc": "Поддержите команду AstrBot через Afdian."
}
}
{
"greeting": {
"morning": "Доброе утро, добро пожаловать в AstrBot",
"afternoon": "Добрый день, добро пожаловать в AstrBot",
"evening": "Добрый вечер, добро пожаловать в AstrBot",
"newYear": "С Новым Годом!"
},
"subtitle": "Сначала пройдите базовое руководство. Настройку платформ и провайдеров моделей можно завершить позже.",
"announcement": {
"title": "Объявление"
},
"onboard": {
"title": "Быстрый старт",
"subtitle": "Вы можете выполнить первичную настройку прямо здесь.",
"step1Title": "Настройка AI моделей",
"step1Desc": "Выберите и настройте AI провайдеров для AstrBot.",
"step2Title": "Настройка платформ",
"step2Desc": "Подключите AstrBot к QQ, Lark, WeChat, Telegram и другим мессенджерам.",
"step3Title": "Разрешить Agent использовать компьютер",
"step3Desc": "Укажите, может ли Agent получать доступ к этому компьютеру и использовать его.",
"step3HelpTitle": "Сведения о доступе",
"step3HelpItem1": "Agent сможет получать доступ к каталогу рабочего пространства и использовать его, при этом рабочие каталоги разных пользователей изолированы друг от друга, а также использовать Skills для выполнения более сложных задач.",
"step3HelpItem2": "Для администраторов AstrBot Agent дополнительно сможет выполнять shell-команды, запускать Python-код и получать доступ ко всем локальным каталогам на этом компьютере.",
"step3HelpClose": "Закрыть",
"step3SelectLabel": "Доступ к компьютеру",
"step3Allow": "Разрешить",
"step3Deny": "Запретить",
"configure": "Настроить",
"save": "Сохранить",
"skip": "Пропустить",
"pending": "Ожидает",
"completed": "Готово",
"skipped": "Пропущено",
"platformLoadFailed": "Ошибка загрузки конфигурации платформ",
"providerLoadFailed": "Ошибка загрузки конфигурации провайдеров",
"providerUpdateFailed": "Ошибка обновления провайдера по умолчанию в файле default",
"providerDefaultUpdated": "Провайдер {id} установлен по умолчанию в файле default",
"computerAccessUpdateFailed": "Не удалось обновить доступ Agent к компьютеру",
"computerAccessAllowed": "Agent теперь может получать доступ к компьютеру и использовать его",
"computerAccessDenied": "Agent больше не может получать доступ к компьютеру и использовать его",
"step3HelpItem3": "Для более тонкой настройки перейдите в «Конфигурация → Основные настройки → Доступ к компьютеру»."
},
"resources": {
"title": "Ресурсы",
"githubDesc": "Поставьте нам звезду на GitHub!",
"docsTitle": "Документация",
"docsDesc": "Официальная документация AstrBot.",
"afdianTitle": "Afdian",
"afdianDesc": "Поддержите команду AstrBot через Afdian."
}
}

View File

@@ -56,6 +56,10 @@
"ipython": {
"output": "输出"
},
"toolStatus": {
"done": "已完成",
"running": "运行中"
},
"conversation": {
"newConversation": "新的聊天",
"noHistory": "暂无对话历史",
@@ -158,4 +162,4 @@
"partialFailure": "{total} 个对话中有 {failed} 个删除失败",
"requestFailed": "删除对话失败,请重试。"
}
}
}

View File

@@ -154,11 +154,11 @@
},
"agent_computer_use": {
"description": "使用电脑能力",
"hint": "让 AstrBot 访问和使用你的电脑或者隔离的沙盒环境,以执行更复杂的任务。详见: [沙盒模式](https://docs.astrbot.app/use/astrbot-agent-sandbox.html), [Skills](https://docs.astrbot.app/use/skills.html)。",
"hint": "让 AstrBot 访问和使用本机环境或者隔离的沙盒环境,以执行更复杂的任务。详见: [电脑使用](https://docs.astrbot.app/use/computer.html)。",
"provider_settings": {
"computer_use_runtime": {
"description": "运行环境",
"hint": "sandbox 代表在沙箱环境中运行, local 代表在本地环境中运行, none 代表不启用。如果上传了 skills选择 none 会导致其无法被 Agent 正常使用。"
"hint": "允许 Agent 访问的环境。local 为本机环境sandbox 为沙箱环境none 为不允许任何环境。"
},
"computer_use_require_admin": {
"description": "需要 AstrBot 管理员权限",
@@ -392,6 +392,10 @@
"description": "Discord 活动名称",
"hint": "可选的 Discord 活动名称。留空则不设置活动。"
},
"discord_allow_bot_messages": {
"description": "允许接收机器人消息",
"hint": "启用后AstrBot 将接收来自其他 Discord 机器人的消息。适用于机器人间通信场景(如消息转发)。默认关闭。"
},
"discord_command_register": {
"description": "注册 Discord 指令",
"hint": "启用后,自动将插件指令注册为 Discord 斜杠指令"
@@ -1115,22 +1119,22 @@
"hint": "仅在使用 qwen3-rerank 模型时生效。建议使用英文撰写。"
},
"nvidia_rerank_api_base": {
"description": "API Base URL"
"description": "API Base URL"
},
"nvidia_rerank_api_key": {
"description": "API Key"
"description": "API Key"
},
"nvidia_rerank_model": {
"description": "重排序模型名称",
"hint": "请参照NVIDIA Docs中模型名称填写。"
"description": "重排序模型名称",
"hint": "请参照NVIDIA Docs中模型名称填写。"
},
"nvidia_rerank_model_endpoint": {
"description": "自定义模型端点",
"hint": "自定义URL末尾端点默认为 /reranking"
"description": "自定义模型端点",
"hint": "自定义URL末尾端点默认为 /reranking"
},
"nvidia_rerank_truncate": {
"description": "文本截断策略",
"hint": "当输入文本过长时,是否截断输入以适应模型的最大上下文长度。"
"description": "文本截断策略",
"hint": "当输入文本过长时,是否截断输入以适应模型的最大上下文长度。"
},
"launch_model_if_not_running": {
"description": "模型未运行时自动启动",
@@ -1639,4 +1643,4 @@
"helpMiddle": "或",
"helpSuffix": "。"
}
}
}

Some files were not shown because too many files have changed in this diff Show More