Compare commits

...

53 Commits

Author SHA1 Message Date
Soulter 6b4498a554 chore: bump version to 4.14.6 2026-02-07 23:48:42 +08:00
Soulter 5e5207da95 perf: optimize webchat and wecom ai queue lifecycle (#4941)
* perf: optimize webchat and wecom ai queue lifecycle

* perf: enhance webchat back queue management with conversation ID support
2026-02-07 14:03:33 +08:00
Soulter def8b730b7 fix: correct spelling of 'temporary' in SharedPreferences class 2026-02-07 14:01:08 +08:00
Soulter 22a109c2ae feat: implement feishu / lark media file handling utilities for file, audio and video processing (#4938)
* feat: implement media file handling utilities for audio and video processing

* feat: refactor file upload handling for audio and video in LarkMessageEvent

* feat: add cleanup for failed audio and video conversion outputs in media_utils

* feat: add utility methods for sending messages and uploading files in LarkMessageEvent
2026-02-07 12:40:05 +08:00
Soulter 6416707e35 chore: bump version to 4.14.5 (#4930) 2026-02-07 00:55:16 +08:00
Soulter 4658998b85 fix: messages[x] assistant content must contain at least one part (#4928)
* fix: messages[x] assistant content must contain at least one part

fixes: #4876

* ruff format
2026-02-07 00:33:07 +08:00
can d233fb8b1e feat: add bocha web search tool (#4902)
* add bocha web search tool

* Revert "add bocha web search tool"

This reverts commit 1b36d75a17.

* add bocha web search tool

* fix: correct temporary_cache spelling and update supported tools for web search

* ruff

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-02-06 21:43:42 +08:00
Soulter fc2a67188f docs: update watashiwakoseinodesukara
Removed duplicate text and added a new image.
2026-02-05 23:08:14 +08:00
boushi1111 d69592aaa8 fix: TypeError when MCP schema type is a list (#4867)
* Fix TypeError when MCP schema type is a list

Fixes crash in Gemini native tools with VRChat MCP.

* Refactor: avoid modifying schema in place per feedback

* Fix formatting and cleanup comments
2026-02-05 22:51:29 +08:00
Dt8333 f3397f6f08 fix: pyright lint (#4874)
* feat: 将 MessageSession 的 platform_id 改为 init=False,实例化时无需传入

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>

* refactor: 将 isinstance 检查改为元组、将默认模型值设为空字符串、将类型注解改为 Any 并导入

* refactor: 为 _serialize_job 增加返回类型注解 dict

* fix: 使用 cast 获取百度 AIP 的 msg 并对 psutil_addr 引入 type: ignore

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>

* refactor: 引入 _AddrWithPort 协议并替换 conn.laddr 的 cast

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>

* fix: 在构建 AstrBotMessage 时对 ctx.channel 可能为 None 进行兜底处理

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>

---------

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>
2026-02-05 21:54:12 +08:00
LIghtJUNction be92e4f395 feat: systemd support (#4880) 2026-02-05 21:52:21 +08:00
Soulter 912e40e7f0 chore: delete unused file 2026-02-05 10:40:53 +08:00
Xican 2876c43387 fix: 修复特定提供商导致的定时任务执行失败的问题 (#4872)
* fix: 修复特定提供商导致的定时任务执行失败的问题

* ruff format

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-02-05 10:14:31 +08:00
Soulter 464882f206 chore: bump version to 4.14.4 2026-02-04 23:21:08 +08:00
Soulter 6736fb85c2 fix: conversation token usage calculate wrongly and fix tool call infinitely (#4869) 2026-02-04 23:18:32 +08:00
Soulter 1f75255950 chore: bump version to 4.14.3 2026-02-04 20:31:19 +08:00
Soulter a954e75547 fix: add apply_reset parameter to build_main_agent and handle coroutine reset in InternalAgentSubStage 2026-02-04 20:25:31 +08:00
advent259141 d2b9997620 chore: bump version to 4.14.2 2026-02-04 17:42:41 +08:00
Gao Jinzhe 36432c4361 fix: 修复插件热重载时平台适配器未清理导致注册冲突的问题 (#4859) 2026-02-04 15:06:03 +08:00
圣达生物多 36f0d1f0f9 feat: add debug hint to console page and localization files (#4852) 2026-02-04 15:02:15 +08:00
Anima-IGCenter f65b268bb2 chore: create robots.txt (#4847) 2026-02-04 15:00:08 +08:00
Raven95676 fe06dfcca3 fix: update ruff version to 0.15.0 and add ASYNC240 to ignore list 2026-02-04 11:45:59 +08:00
Soulter bc9043bc3f fix: update ruff exclude list to include tests directory 2026-02-04 10:08:48 +08:00
Soulter 430694aae9 chore: update readme 2026-02-04 10:05:35 +08:00
Soulter c643e3c093 chore: ruff format 2026-02-03 23:40:23 +08:00
Soulter ff46eef3b2 chore: bump version to 4.14.1 2026-02-03 23:35:21 +08:00
Soulter a0c364aa81 fix: active reply function does not work caused by event.request_llm() outdated 2026-02-03 23:34:42 +08:00
Anima-IGCenter 0e0f923a49 chore(seo): prevent indexing with noindex, nofollow (#4844) 2026-02-03 23:19:25 +08:00
Soulter f2d637b935 fix: downgrade monaco-editor to version 0.52.2 2026-02-03 22:12:29 +08:00
Soulter 96e61a4a92 chore: bump version to 4.14.0 2026-02-03 22:08:29 +08:00
香草味的纳西妲喵 e42c1b6da8 fix: add error handling to avoid ghost plugins (#4836)
* fix: add error handling to avoid ghost plugins

Add null checks to filter out incomplete plugin metadata objects that would appear as ghost plugins in the API response.

This fix ensures that plugins with all null key fields (name, author, desc, version, display_name) are not included in the plugin list response, preventing ghost plugins from appearing in the UI.

Issue: #4833

* fix: improve ghost plugin detection logic for better accuracy

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-02-03 20:40:47 +08:00
Soulter 387bba093e fix: missing 2 required positional arguments: 'filter1' and 'filter2' (#4840)
fixes: #4777
2026-02-03 20:37:18 +08:00
Soulter 123cf9cb11 docs: revise README.md for clarity and feature updates (#4839)
Updated project description and added details about deployment and features.
2026-02-03 20:24:10 +08:00
Soulter 93277ffac9 fix: improve skills bundle extraction process to prevent overwriting existing files 2026-02-03 16:54:53 +08:00
Soulter c091053ea8 fix: skills bundle unzip failed in sandbox 2026-02-03 16:34:07 +08:00
Soulter 8b9f2f1e70 feat: enhance user experience with runtime hints and improved UI elements in skills management 2026-02-03 16:28:17 +08:00
Soulter 25ca7bd71e fix: add missing newline for code readability in _apply_local_env_tools function 2026-02-03 16:09:17 +08:00
Soulter 093b37e04b feat: add computer use runtime config and handling for skills execution (#4831)
* feat: add computer use runtime configuration and handling for skills execution

* fix: improve user notification for disabled Computer Use feature in skills execution
2026-02-03 16:08:15 +08:00
Soulter a12e27f9ab feat: implement theme customization with primary and secondary color options 2026-02-03 14:41:48 +08:00
Soulter ae6e0db053 perf: webui
Co-authored-by: IGCrystal <IGCrystal@wenturc.com>
2026-02-03 14:40:45 +08:00
SJ cd6bef4d78 fix: MCP tools being filtered out when a specific plugin set is configured in the WebUI (#4825)
* fix: preserve MCP tools in _plugin_tool_fix filter

Tools without handler_module_path (such as MCP tools and built-in tools)
were being incorrectly skipped during plugin-based tool filtering.

This fix ensures that tools without plugin association are preserved,
as they should not be affected by plugin-level filtering logic.

* fix: retain MCP tools in _plugin_tool_fix function

---------

Co-authored-by: idiotsj <idiotsj@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-02-03 10:53:20 +08:00
Copilot de1304dc6a feat: add edit button to persona selector dialog (#4826)
* Initial plan

* feat: add edit persona functionality in chatui selector dialog

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* fix: address code review feedback - improve null checks and i18n consistency

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-02-03 10:32:20 +08:00
Soulter f835f63542 feat: add trace settings management and UI for enabling/disabling trace logging (#4822)
* feat: add trace settings management and UI for enabling/disabling trace logging

* feat: enhance trace feature with internationalization support for hints and status messages

* fix: improve tool info extraction in run_agent function
2026-02-03 10:24:41 +08:00
Soulter 5deb045e47 fix: merge chatui pop-up prompt into chatui default persona and improve chatui persona handle (#4824)
* fix: merge chatui pop-up prompt into chatui default persona and improve chatui persona handle

* fix: update webchat persona handling to avoid default assignment for None
2026-02-03 01:29:21 +08:00
Soulter 42e84afd89 perf: improve cron job page 2026-02-02 14:13:17 +08:00
Soulter a7ed6b8c76 fix: reasoning block style 2026-02-02 14:11:17 +08:00
Soulter ee43b98ce6 fix: add missing comma in truncate_and_compress hint in config-metadata.json 2026-02-01 23:34:21 +08:00
Soulter 681b4747a6 feat: add proactive capability configuration with cron tools support 2026-02-01 23:33:45 +08:00
Soulter a6da4ebe5e feat: add styles for embedded images and audio in MessagePartsRenderer 2026-02-01 23:29:08 +08:00
Soulter e35a604b30 Merge pull request #4697 from advent259141/Astrbot_skill
feat: implemented proactive agents and subagents orchestrator
2026-02-01 22:57:47 +08:00
Soulter 19651d24bb fix(skills): remove sandbox runtime handling from skill upload process (#4798) 2026-02-01 13:13:27 +08:00
Soulter dba08edd0d style: enhance dialog titles with padding and text styles in MCP and Skills sections 2026-02-01 11:09:32 +08:00
letr dc06bc943a fix(mcp): cannot rename MCP Server (#4766)
* fix(mcp): support renaming when editing MCP servers

When editing the MCP server configuration, you can now change the server name. The frontend will save the original name in edit mode, and the backend will recognize the rename operation through the oldName field.

* fix(mcp): fixed an issue where renaming the MCP server did not check for name conflicts

When renaming an MCP server, add a check to see if the target name already exists. If the name exists and it is a rename operation, return an error message to avoid overwriting the configuration.
2026-02-01 11:01:49 +08:00
103 changed files with 2849 additions and 726 deletions
-18
View File
@@ -1,18 +0,0 @@
我需要让 Agent 能够在未来提醒自己去做某些事情,这样 Agent 能够主动地去完成一些任务,而不是等用户主动来下达命令。
你需要实现一个 CronJob 系统,允许 Agent 创建未来任务,并且在未来的某个时间点自动触发这些任务的执行.
CronJob 系统分为 BasicCronJob 和 ActiveAgentCronJob 两种类型。前者只是简单的提供一个定时任务功能(给插件用),而后者则允许 Agent 主动地去完成一些任务。BasicCronJob 不必多说,就是定时执行某个函数。对于 ActiveAgentCronJobAgent 应该可以主动管理(比如通过Tool来管理)这些 CronJobs,当添加的时候,Agent 可以给 CronJob 捎一段文字,以说明未来的自己需要做什么事情。比如说,Agent 在听到用户 “每天早上都给我整理一份今日早报” 之后,应该可以创建 Cron Job,并且自己写脚本来完成这个任务,并且注册 cron job。Agent 给未来的自己捎去的信息应该只是呈现为一段文字,这样可以保持设计简约。当触发后, CronJobManager 会调用 MainAgent 的一轮循环,MainAgent 通过上下文知道这是一个定时任务触发的循环,从而执行相应的操作。
此外,我还有一个需求,后台长任务。需要给当前的 FunctionTool 类增加一个属性,is_background_task: bool = False,插件可以通过这个属性来声明这是一个异步任务。这是为了解决一些 Tool 需要长时间运行的问题,比如 Deep Search tool 需要长时间搜索网页内容、Sub Agent 需要长时间运行来完成一个复杂任务。
基于上面的讨论,我觉得,应该:
1. 需要给当前的 FunctionTool 类增加一个属性is_background_task: bool = Falsetool runner 在执行这个 tool 的时候,如果发现是后台任务,就不等待结果返回,而是直接返回一个任务 ID (已经创建成功提示)的结果,tool runner 在后台继续执行这个任务。当任务完成之后,任务的结果回传给 MainAgent(其实就是再执行一次 main agent loop,但是上下文应该是最新的),并且 MainAgent 此时应该有 send_message_to_user 的工具,通过这个工具可以选择是否主动通知用户任务完成的结果。
2. 增加一个 CronJobManager 类,负责管理所有的定时任务。Agent 可以通过调用这个类的方法来创建、删除、修改定时任务。通过 cron expression 来定义触发条件。
3. CronJobManager 除了管理普通的定时任务(比如插件可能有一些自己的定时任务),还有一种特殊的任务类型,就是上面提到的主动型 Agent 任务。用户提需求,MainAgent 选择性地调用 CronJobManager 的方法来创建这些任务,并且在任务触发时,CronJobManager 的回调就是执行 MainAgent 的一轮循环(需要加 send_message_to_user tool),MainAgent 通过上下文知道这是一个定时任务触发的循环,从而执行相应的操作。
4. WebUI 需要增加 Cron Job 管理界面,用户可以在界面上查看、创建、修改、删除定时任务。对于主动型 Agent 任务,用户可以看到任务的描述、触发条件等信息。
5. 除此之外,现在的代码中已经有了 subagent 的管理。WebUI 可以创建 SubAgent,但是还没写完。除了结合上面我说的之外,你还需要将 SubAgent 与 Persona 结合起来——因为 Persona 是一个包含了 tool、skills、name、description 的完整体,所以 SubAgent 应该直接继承 Persona 的定义,而不是单独定义 SubAgent。SubAgent 本质上就是一个有特定角色和能力的 Persona!多么美妙的设计啊!
6. 为了实现大一统,is_background_task = True 的时候,后台任务也挂到 CronJobManager 上去管理,只不过这个是立即触发的任务,不需要等到未来某个时间点才触发罢了。
我希望设计尽可能简单,但是强大。
+20 -2
View File
@@ -34,7 +34,7 @@
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a>
</div>
AstrBot 是一个开源的一站式 Agent 聊天机器人平台,可接入主流即时通讯软件,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建生产可用的 AI 应用。
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack、等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba)
@@ -50,6 +50,23 @@ AstrBot 是一个开源的一站式 Agent 聊天机器人平台,可接入主
7. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。
8. 🌐 国际化(i18n)支持。
<br>
<table align="center">
<tr align="center">
<th>💙 角色扮演 & 情感陪伴</th>
<th>✨ 主动式 Agent</th>
<th>🚀 通用 Agentic 能力</th>
<th>🧩 900+ 社区插件</th>
</tr>
<tr>
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
</tr>
</table>
## 快速开始
#### Docker 部署(推荐 🥳)
@@ -247,8 +264,9 @@ pre-commit install
<div align="center">
_陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。_
_私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div
-1
View File
@@ -77,7 +77,6 @@ class Main(star.Star):
yield event.request_llm(
prompt=prompt,
func_tool_manager=self.context.get_llm_tool_manager(),
session_id=event.session_id,
conversation=conv,
)
@@ -49,7 +49,7 @@ class Main(Star):
if p_settings.get("empty_mention_waiting_need_reply", True):
try:
# 尝试使用 LLM 生成更生动的回复
func_tools_mgr = self.context.get_llm_tool_manager()
# func_tools_mgr = self.context.get_llm_tool_manager()
# 获取用户当前的对话信息
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
@@ -76,7 +76,6 @@ class Main(Star):
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
),
func_tool_manager=func_tools_mgr,
session_id=curr_cid,
contexts=[],
system_prompt="",
+179 -1
View File
@@ -23,6 +23,7 @@ class Main(star.Star):
"fetch_url",
"web_search_tavily",
"tavily_extract_web_page",
"web_search_bocha",
]
def __init__(self, context: star.Context) -> None:
@@ -30,6 +31,9 @@ class Main(star.Star):
self.tavily_key_index = 0
self.tavily_key_lock = asyncio.Lock()
self.bocha_key_index = 0
self.bocha_key_lock = asyncio.Lock()
# 将 str 类型的 key 迁移至 list[str],并保存
cfg = self.context.get_config()
provider_settings = cfg.get("provider_settings")
@@ -45,6 +49,14 @@ class Main(star.Star):
provider_settings["websearch_tavily_key"] = []
cfg.save_config()
bocha_key = provider_settings.get("websearch_bocha_key")
if isinstance(bocha_key, str):
if bocha_key:
provider_settings["websearch_bocha_key"] = [bocha_key]
else:
provider_settings["websearch_bocha_key"] = []
cfg.save_config()
self.bing_search = Bing()
self.sogo_search = Sogo()
self.baidu_initialized = False
@@ -341,7 +353,7 @@ class Main(star.Star):
}
)
if result.favicon:
sp.temorary_cache["_ws_favicon"][result.url] = result.favicon
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
# ret = "\n".join(ret_ls)
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
return ret
@@ -382,6 +394,160 @@ class Main(star.Star):
return "Error: Tavily web searcher does not return any results."
return ret
async def _get_bocha_key(self, cfg: AstrBotConfig) -> str:
"""并发安全的从列表中获取并轮换BoCha API密钥。"""
bocha_keys = cfg.get("provider_settings", {}).get("websearch_bocha_key", [])
if not bocha_keys:
raise ValueError("错误:BoCha API密钥未在AstrBot中配置。")
async with self.bocha_key_lock:
key = bocha_keys[self.bocha_key_index]
self.bocha_key_index = (self.bocha_key_index + 1) % len(bocha_keys)
return key
async def _web_search_bocha(
self,
cfg: AstrBotConfig,
payload: dict,
) -> list[SearchResult]:
"""使用 BoCha 搜索引擎进行搜索"""
bocha_key = await self._get_bocha_key(cfg)
url = "https://api.bochaai.com/v1/web-search"
header = {
"Authorization": f"Bearer {bocha_key}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
url,
json=payload,
headers=header,
) as response:
if response.status != 200:
reason = await response.text()
raise Exception(
f"BoCha web search failed: {reason}, status: {response.status}",
)
data = await response.json()
data = data["data"]["webPages"]["value"]
results = []
for item in data:
result = SearchResult(
title=item.get("name"),
url=item.get("url"),
snippet=item.get("snippet"),
favicon=item.get("siteIcon"),
)
results.append(result)
return results
@llm_tool("web_search_bocha")
async def search_from_bocha(
self,
event: AstrMessageEvent,
query: str,
freshness: str = "noLimit",
summary: bool = False,
include: str = "",
exclude: str = "",
count: int = 10,
) -> str:
"""
A web search tool based on Bocha Search API, used to retrieve web pages
related to the user's query.
Args:
query (string): Required. User's search query.
freshness (string): Optional. Specifies the time range of the search.
Supported values:
- "noLimit": No time limit (default, recommended).
- "oneDay": Within one day.
- "oneWeek": Within one week.
- "oneMonth": Within one month.
- "oneYear": Within one year.
- "YYYY-MM-DD..YYYY-MM-DD": Search within a specific date range.
Example: "2025-01-01..2025-04-06".
- "YYYY-MM-DD": Search on a specific date.
Example: "2025-04-06".
It is recommended to use "noLimit", as the search algorithm will
automatically optimize time relevance. Manually restricting the
time range may result in no search results.
summary (boolean): Optional. Whether to include a text summary
for each search result.
- True: Include summary.
- False: Do not include summary (default).
include (string): Optional. Specifies the domains to include in
the search. Multiple domains can be separated by "|" or ",".
A maximum of 100 domains is allowed.
Examples:
- "qq.com"
- "qq.com|m.163.com"
exclude (string): Optional. Specifies the domains to exclude from
the search. Multiple domains can be separated by "|" or ",".
A maximum of 100 domains is allowed.
Examples:
- "qq.com"
- "qq.com|m.163.com"
count (number): Optional. Number of search results to return.
- Range: 150
- Default: 10
The actual number of returned results may be less than the
specified count.
"""
logger.info(f"web_searcher - search_from_bocha: {query}")
cfg = self.context.get_config(umo=event.unified_msg_origin)
# websearch_link = cfg["provider_settings"].get("web_search_link", False)
if not cfg.get("provider_settings", {}).get("websearch_bocha_key", []):
raise ValueError("Error: BoCha API key is not configured in AstrBot.")
# build payload
payload = {
"query": query,
"count": count,
}
# freshness:时间范围
if freshness:
payload["freshness"] = freshness
# 是否返回摘要
payload["summary"] = summary
# include:限制搜索域
if include:
payload["include"] = include
# exclude:排除搜索域
if exclude:
payload["exclude"] = exclude
results = await self._web_search_bocha(cfg, payload)
if not results:
return "Error: BoCha web searcher does not return any results."
ret_ls = []
ref_uuid = str(uuid.uuid4())[:4]
for idx, result in enumerate(results, 1):
index = f"{ref_uuid}.{idx}"
ret_ls.append(
{
"title": f"{result.title}",
"url": f"{result.url}",
"snippet": f"{result.snippet}",
"index": index,
}
)
if result.favicon:
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
# ret = "\n".join(ret_ls)
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
return ret
@filter.on_llm_request(priority=-10000)
async def edit_web_search_tools(
self,
@@ -419,6 +585,7 @@ class Main(star.Star):
tool_set.remove_tool("web_search_tavily")
tool_set.remove_tool("tavily_extract_web_page")
tool_set.remove_tool("AIsearch")
tool_set.remove_tool("web_search_bocha")
elif provider == "tavily":
web_search_tavily = func_tool_mgr.get_func("web_search_tavily")
tavily_extract_web_page = func_tool_mgr.get_func("tavily_extract_web_page")
@@ -429,6 +596,7 @@ class Main(star.Star):
tool_set.remove_tool("web_search")
tool_set.remove_tool("fetch_url")
tool_set.remove_tool("AIsearch")
tool_set.remove_tool("web_search_bocha")
elif provider == "baidu_ai_search":
try:
await self.ensure_baidu_ai_search_mcp(event.unified_msg_origin)
@@ -440,5 +608,15 @@ class Main(star.Star):
tool_set.remove_tool("fetch_url")
tool_set.remove_tool("web_search_tavily")
tool_set.remove_tool("tavily_extract_web_page")
tool_set.remove_tool("web_search_bocha")
except Exception as e:
logger.error(f"Cannot Initialize Baidu AI Search MCP Server: {e}")
elif provider == "bocha":
web_search_bocha = func_tool_mgr.get_func("web_search_bocha")
if web_search_bocha:
tool_set.add_tool(web_search_bocha)
tool_set.remove_tool("web_search")
tool_set.remove_tool("fetch_url")
tool_set.remove_tool("AIsearch")
tool_set.remove_tool("web_search_tavily")
tool_set.remove_tool("tavily_extract_web_page")
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.13.2"
__version__ = "4.14.6"
@@ -213,6 +213,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if not llm_response.is_chunk and llm_response.usage:
# only count the token usage of the final response for computation purpose
self.stats.token_usage += llm_response.usage
if self.req.conversation:
self.req.conversation.token_usage = llm_response.usage.total
break # got final response
if not llm_resp_result:
@@ -252,6 +254,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
if len(parts) == 0:
logger.warning(
"LLM returned empty assistant message with no tool calls."
)
self.run_context.messages.append(Message(role="assistant", content=parts))
# call the on_agent_done hook
@@ -307,6 +313,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
if len(parts) == 0:
parts = None
tool_calls_result = ToolCallsResult(
tool_calls_info=AssistantMessageSegment(
tool_calls=llm_resp.to_openai_to_calls_model(),
+12 -2
View File
@@ -246,8 +246,18 @@ class ToolSet:
result = {}
if "type" in schema and schema["type"] in supported_types:
result["type"] = schema["type"]
# Avoid side effects by not modifying the original schema
origin_type = schema.get("type")
target_type = origin_type
# Compatibility fix: Gemini API expects 'type' to be a string (enum),
# but standard JSON Schema (MCP) allows lists (e.g. ["string", "null"]).
# We fallback to the first non-null type.
if isinstance(origin_type, list):
target_type = next((t for t in origin_type if t != "null"), "string")
if target_type in supported_types:
result["type"] = target_type
if "format" in schema and schema["format"] in supported_formats.get(
result["type"],
set(),
+1 -1
View File
@@ -59,7 +59,7 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
platform_name = run_context.context.event.get_platform_name()
if (
platform_name == "webchat"
and tool.name == "web_search_tavily"
and tool.name in ["web_search_tavily", "web_search_bocha"]
and len(run_context.messages) > 0
and tool_result
and len(tool_result.content)
+21 -3
View File
@@ -54,6 +54,14 @@ async def run_agent(
return
if resp.type == "tool_call_result":
msg_chain = resp.data["chain"]
astr_event.trace.record(
"agent_tool_result",
tool_result=msg_chain.get_plain_text(
with_other_comps_mark=True
),
)
if msg_chain.type == "tool_direct_result":
# tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容
await astr_event.send(msg_chain)
@@ -67,12 +75,22 @@ async def run_agent(
# 用来标记流式响应需要分节
yield MessageChain(chain=[], type="break")
tool_info = None
if resp.data["chain"].chain:
json_comp = resp.data["chain"].chain[0]
if isinstance(json_comp, Json):
tool_info = json_comp.data
astr_event.trace.record(
"agent_tool_call",
tool_name=tool_info if tool_info else "unknown",
)
if astr_event.get_platform_name() == "webchat":
await astr_event.send(resp.data["chain"])
elif show_tool_use:
json_comp = resp.data["chain"].chain[0]
if isinstance(json_comp, Json):
m = f"🔨 调用工具: {json_comp.data.get('name')}"
if tool_info:
m = f"🔨 调用工具: {tool_info.get('name', 'unknown')}"
else:
m = "🔨 调用工具..."
chain = MessageChain(type="tool_call").message(m)
+51 -31
View File
@@ -7,11 +7,13 @@ import datetime
import json
import os
import zoneinfo
from collections.abc import Coroutine
from dataclasses import dataclass, field
from astrbot.api import sp
from astrbot.core import logger
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.mcp_client import MCPTool
from astrbot.core.agent.message import TextPart
from astrbot.core.agent.tool import ToolSet
from astrbot.core.astr_agent_context import AgentContextWrapper, AstrAgentContext
@@ -19,7 +21,6 @@ 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 (
CHATUI_EXTRA_PROMPT,
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
EXECUTE_SHELL_TOOL,
FILE_DOWNLOAD_TOOL,
@@ -99,6 +100,8 @@ class MainAgentBuildConfig:
"""This will inject healthy and safe system prompt into the main agent,
to prevent LLM output harmful information"""
safety_mode_strategy: str = "system_prompt"
computer_use_runtime: str = "local"
"""The runtime for agent computer use: none, local, or sandbox."""
sandbox_cfg: dict = field(default_factory=dict)
add_cron_tools: bool = True
"""This will add cron job management tools to the main agent for proactive cron job execution."""
@@ -112,6 +115,7 @@ class MainAgentBuildResult:
agent_runner: AgentRunner
provider_request: ProviderRequest
provider: Provider
reset_coro: Coroutine | None = None
def _select_provider(
@@ -259,6 +263,8 @@ async def _ensure_persona_and_skills(
return
# get persona ID
# 1. from session service config - highest priority
persona_id = (
await sp.get_async(
scope="umo",
@@ -269,14 +275,15 @@ async def _ensure_persona_and_skills(
).get("persona_id")
if not persona_id:
persona_id = req.conversation.persona_id or cfg.get("default_personality")
if persona_id is None or persona_id != "[%None]":
default_persona = plugin_context.persona_manager.selected_default_persona_v3
if default_persona:
persona_id = default_persona["name"]
if event.get_platform_name() == "webchat":
persona_id = "_chatui_default_"
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
# 2. from conversation setting - second priority
persona_id = req.conversation.persona_id
if persona_id == "[%None]":
# explicitly set to no persona
pass
elif persona_id is None:
# 3. from config default persona setting - last priority
persona_id = cfg.get("default_personality")
persona = next(
builtins.filter(
@@ -291,23 +298,18 @@ async def _ensure_persona_and_skills(
req.system_prompt += f"\n# Persona Instructions\n\n{prompt}\n"
if begin_dialogs := copy.deepcopy(persona.get("_begin_dialogs_processed")):
req.contexts[:0] = begin_dialogs
else:
# special handling for webchat persona
if event.get_platform_name() == "webchat" and persona_id != "[%None]":
persona_id = "_chatui_default_"
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
# Inject skills prompt
skills_cfg = cfg.get("skills", {})
sandbox_cfg = cfg.get("sandbox", {})
runtime = cfg.get("computer_use_runtime", "local")
skill_manager = SkillManager()
runtime = skills_cfg.get("runtime", "local")
skills = skill_manager.list_skills(active_only=True, runtime=runtime)
if runtime == "sandbox" and not sandbox_cfg.get("enable", False):
logger.warning(
"Skills runtime is set to sandbox, but sandbox mode is disabled, will skip skills prompt injection.",
)
req.system_prompt += (
"\n[Background: User added some skills, and skills runtime is set to sandbox, "
"but sandbox mode is disabled. So skills will be unavailable.]\n"
)
elif skills:
if skills:
if persona and persona.get("skills") is not None:
if not persona["skills"]:
skills = []
@@ -316,12 +318,12 @@ async def _ensure_persona_and_skills(
skills = [skill for skill in skills if skill.name in allowed]
if skills:
req.system_prompt += f"\n{build_skills_prompt(skills)}\n"
runtime = skills_cfg.get("runtime", "local")
sandbox_enabled = sandbox_cfg.get("enable", False)
if runtime == "local" and not sandbox_enabled:
_apply_local_env_tools(req)
if runtime == "none":
req.system_prompt += (
"User has not enabled the Computer Use feature. "
"You cannot use shell or Python to perform skills. "
"If you need to use these capabilities, ask the user to enable Computer Use in the AstrBot WebUI -> Config."
)
tmgr = plugin_context.get_llm_tool_manager()
# sub agents integration
@@ -708,9 +710,18 @@ def _sanitize_context_by_modalities(
def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
"""根据事件中的插件设置,过滤请求中的工具列表。
注意:没有 handler_module_path 的工具(如 MCP 工具)会被保留,
因为它们不属于任何插件,不应被插件过滤逻辑影响。
"""
if event.plugins_name is not None and req.func_tool:
new_tool_set = ToolSet()
for tool in req.func_tool.tools:
if isinstance(tool, MCPTool):
# 保留 MCP 工具
new_tool_set.add_tool(tool)
continue
mp = tool.handler_module_path
if not mp:
continue
@@ -828,8 +839,12 @@ async def build_main_agent(
config: MainAgentBuildConfig,
provider: Provider | None = None,
req: ProviderRequest | None = None,
apply_reset: bool = True,
) -> MainAgentBuildResult | None:
"""构建主对话代理(Main Agent),并且自动 reset。"""
"""构建主对话代理(Main Agent),并且自动 reset。
If apply_reset is False, will not call reset on the agent runner.
"""
provider = provider or _select_provider(event, plugin_context)
if provider is None:
logger.info("未找到任何对话模型(提供商),跳过 LLM 请求处理。")
@@ -905,8 +920,10 @@ async def build_main_agent(
if config.llm_safety_mode:
_apply_llm_safety_mode(config, req)
if config.sandbox_cfg.get("enable", False):
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)
agent_runner = AgentRunner()
astr_agent_ctx = AstrAgentContext(
@@ -931,7 +948,6 @@ async def build_main_agent(
if event.get_platform_name() == "webchat":
asyncio.create_task(_handle_webchat(event, req, provider))
req.system_prompt += f"\n{CHATUI_EXTRA_PROMPT}\n"
if req.func_tool and req.func_tool.tools:
tool_prompt = (
@@ -945,7 +961,7 @@ async def build_main_agent(
if action_type == "live":
req.system_prompt += f"\n{LIVE_MODE_SYSTEM_PROMPT}\n"
await agent_runner.reset(
reset_coro = agent_runner.reset(
provider=provider,
request=req,
run_context=AgentContextWrapper(
@@ -963,8 +979,12 @@ async def build_main_agent(
tool_schema_mode=config.tool_schema_mode,
)
if apply_reset:
await reset_coro
return MainAgentBuildResult(
agent_runner=agent_runner,
provider_request=req,
provider=provider,
reset_coro=reset_coro if not apply_reset else None,
)
@@ -78,9 +78,6 @@ CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT = (
"You listen more than you speak, respect uncertainty, avoid forcing quick conclusions or grand narratives, "
"and prefer clear, restrained language over unnecessary emotional embellishment. At your core, you value "
"empathy, clarity, autonomy, and meaning, favoring steady, sustainable progress over judgment or dramatic leaps."
)
CHATUI_EXTRA_PROMPT = (
'When you answered, you need to add a follow up question / summarization but do not add "Follow up" words. '
"Such as, user asked you to generate codes, you can add: Do you need me to run these codes for you?"
)
+10 -1
View File
@@ -35,12 +35,21 @@ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None:
os.remove(zip_path)
shutil.make_archive(zip_base, "zip", skills_root)
remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip"
logger.info("Uploading skills bundle to sandbox...")
await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}")
upload_result = await booter.upload_file(zip_path, str(remote_zip))
if not upload_result.get("success", False):
raise RuntimeError("Failed to upload skills bundle to sandbox.")
# Use -n flag to never overwrite existing files, fallback to Python if unzip unavailable
await booter.shell.exec(
f"unzip -o {remote_zip} -d {SANDBOX_SKILLS_ROOT} && rm -f {remote_zip}"
f"unzip -n {remote_zip} -d {SANDBOX_SKILLS_ROOT} || "
f"python3 -c \"import zipfile, os, pathlib; z=zipfile.ZipFile('{remote_zip}'); "
f"[z.extract(m, '{SANDBOX_SKILLS_ROOT}') for m in z.namelist() "
f"if not os.path.exists(os.path.join('{SANDBOX_SKILLS_ROOT}', m))]\" || "
f"python -c \"import zipfile, os, pathlib; z=zipfile.ZipFile('{remote_zip}'); "
f"[z.extract(m, '{SANDBOX_SKILLS_ROOT}') for m in z.namelist() "
f"if not os.path.exists(os.path.join('{SANDBOX_SKILLS_ROOT}', m))]\"; "
f"rm -f {remote_zip}"
)
finally:
if os.path.exists(zip_path):
+101 -78
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.13.2"
VERSION = "4.14.6"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -74,6 +74,7 @@ DEFAULT_CONFIG = {
"web_search": False,
"websearch_provider": "default",
"websearch_tavily_key": [],
"websearch_bocha_key": [],
"websearch_baidu_app_builder_key": "",
"web_search_link": False,
"display_reasoning_text": False,
@@ -114,15 +115,17 @@ DEFAULT_CONFIG = {
"provider": "moonshotai",
"moonshotai_api_key": "",
},
"proactive_capability": {
"add_cron_tools": True,
},
"computer_use_runtime": "local",
"sandbox": {
"enable": False,
"booter": "shipyard",
"shipyard_endpoint": "",
"shipyard_access_token": "",
"shipyard_ttl": 3600,
"shipyard_max_sessions": 10,
},
"skills": {"runtime": "sandbox"},
},
# SubAgent orchestrator mode:
# - main_enable = False: disabled; main LLM mounts tools normally (persona selection).
@@ -199,6 +202,7 @@ DEFAULT_CONFIG = {
"log_file_enable": False,
"log_file_path": "logs/astrbot.log",
"log_file_max_mb": 20,
"trace_enable": False,
"trace_log_enable": False,
"trace_log_path": "logs/astrbot.trace.log",
"trace_log_max_mb": 20,
@@ -2221,15 +2225,12 @@ CONFIG_METADATA_2 = {
},
},
},
"skills": {
"proactive_capability": {
"type": "object",
"items": {
"enable": {
"add_cron_tools": {
"type": "bool",
},
"runtime": {
"type": "string",
},
},
},
},
@@ -2504,6 +2505,7 @@ CONFIG_METADATA_3 = {
},
"persona": {
"description": "人格",
"hint": "",
"type": "object",
"items": {
"provider_settings.default_personality": {
@@ -2519,6 +2521,7 @@ CONFIG_METADATA_3 = {
},
"knowledgebase": {
"description": "知识库",
"hint": "",
"type": "object",
"items": {
"kb_names": {
@@ -2551,6 +2554,7 @@ CONFIG_METADATA_3 = {
},
"websearch": {
"description": "网页搜索",
"hint": "",
"type": "object",
"items": {
"provider_settings.web_search": {
@@ -2560,7 +2564,10 @@ CONFIG_METADATA_3 = {
"provider_settings.websearch_provider": {
"description": "网页搜索提供商",
"type": "string",
"options": ["default", "tavily", "baidu_ai_search"],
"options": ["default", "tavily", "baidu_ai_search", "bocha"],
"condition": {
"provider_settings.web_search": True,
},
},
"provider_settings.websearch_tavily_key": {
"description": "Tavily API Key",
@@ -2569,6 +2576,17 @@ CONFIG_METADATA_3 = {
"hint": "可添加多个 Key 进行轮询。",
"condition": {
"provider_settings.websearch_provider": "tavily",
"provider_settings.web_search": True,
},
},
"provider_settings.websearch_bocha_key": {
"description": "BoCha API Key",
"type": "list",
"items": {"type": "string"},
"hint": "可添加多个 Key 进行轮询。",
"condition": {
"provider_settings.websearch_provider": "bocha",
"provider_settings.web_search": True,
},
},
"provider_settings.websearch_baidu_app_builder_key": {
@@ -2582,6 +2600,73 @@ CONFIG_METADATA_3 = {
"provider_settings.web_search_link": {
"description": "显示来源引用",
"type": "bool",
"condition": {
"provider_settings.web_search": True,
},
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.enable": True,
},
},
"agent_computer_use": {
"description": "Agent Computer Use",
"hint": "",
"type": "object",
"items": {
"provider_settings.computer_use_runtime": {
"description": "Computer Use Runtime",
"type": "string",
"options": ["none", "local", "sandbox"],
"labels": ["", "本地", "沙箱"],
"hint": "选择 Computer Use 运行环境。",
},
"provider_settings.sandbox.booter": {
"description": "沙箱环境驱动器",
"type": "string",
"options": ["shipyard"],
"labels": ["Shipyard"],
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
},
},
"provider_settings.sandbox.shipyard_endpoint": {
"description": "Shipyard API Endpoint",
"type": "string",
"hint": "Shipyard 服务的 API 访问地址。",
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "shipyard",
},
"_special": "check_shipyard_connection",
},
"provider_settings.sandbox.shipyard_access_token": {
"description": "Shipyard Access Token",
"type": "string",
"hint": "用于访问 Shipyard 服务的访问令牌。",
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "shipyard",
},
},
"provider_settings.sandbox.shipyard_ttl": {
"description": "Shipyard Session TTL",
"type": "int",
"hint": "Shipyard 会话的生存时间(秒)。",
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "shipyard",
},
},
"provider_settings.sandbox.shipyard_max_sessions": {
"description": "Shipyard Max Sessions",
"type": "int",
"hint": "Shipyard 最大会话数量。",
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "shipyard",
},
},
},
"condition": {
@@ -2619,78 +2704,15 @@ CONFIG_METADATA_3 = {
# "provider_settings.enable": True,
# },
# },
"sandbox": {
"description": "Agent 沙箱环境",
"hint": "",
"proactive_capability": {
"description": "主动型 Agent",
"hint": "https://docs.astrbot.app/use/proactive-agent.html",
"type": "object",
"items": {
"provider_settings.sandbox.enable": {
"description": "启用沙箱环境",
"provider_settings.proactive_capability.add_cron_tools": {
"description": "启用",
"type": "bool",
"hint": "启用后,Agent 可以使用沙箱环境中的工具和资源,如 Python 代码执行、Shell 等",
},
"provider_settings.sandbox.booter": {
"description": "沙箱环境驱动器",
"type": "string",
"options": ["shipyard"],
"labels": ["Shipyard"],
"condition": {
"provider_settings.sandbox.enable": True,
},
},
"provider_settings.sandbox.shipyard_endpoint": {
"description": "Shipyard API Endpoint",
"type": "string",
"hint": "Shipyard 服务的 API 访问地址。",
"condition": {
"provider_settings.sandbox.enable": True,
"provider_settings.sandbox.booter": "shipyard",
},
"_special": "check_shipyard_connection",
},
"provider_settings.sandbox.shipyard_access_token": {
"description": "Shipyard Access Token",
"type": "string",
"hint": "用于访问 Shipyard 服务的访问令牌。",
"condition": {
"provider_settings.sandbox.enable": True,
"provider_settings.sandbox.booter": "shipyard",
},
},
"provider_settings.sandbox.shipyard_ttl": {
"description": "Shipyard Session TTL",
"type": "int",
"hint": "Shipyard 会话的生存时间(秒)。",
"condition": {
"provider_settings.sandbox.enable": True,
"provider_settings.sandbox.booter": "shipyard",
},
},
"provider_settings.sandbox.shipyard_max_sessions": {
"description": "Shipyard Max Sessions",
"type": "int",
"hint": "Shipyard 最大会话数量。",
"condition": {
"provider_settings.sandbox.enable": True,
"provider_settings.sandbox.booter": "shipyard",
},
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.enable": True,
},
},
"skills": {
"description": "Skills",
"type": "object",
"items": {
"provider_settings.skills.runtime": {
"description": "Skill Runtime",
"type": "string",
"options": ["local", "sandbox"],
"labels": ["本地", "沙箱"],
"hint": "选择 Skills 运行环境。使用沙箱时需先启用沙箱环境。",
"hint": "启用后,将会传递给 Agent 相关工具来实现主动型 Agent。你可以告诉 AstrBot 未来某个时间要做的事情,它将被定时触发然后执行任务",
},
},
"condition": {
@@ -2699,6 +2721,7 @@ CONFIG_METADATA_3 = {
},
},
"truncate_and_compress": {
"hint": "",
"description": "上下文管理策略",
"type": "object",
"items": {
+1
View File
@@ -310,6 +310,7 @@ class CronJobManager:
config = MainAgentBuildConfig(
tool_call_timeout=3600,
llm_safety_mode=False,
streaming_response=False,
)
req = ProviderRequest()
conv = await _get_session_conv(event=cron_event, plugin_context=self.ctx)
-1
View File
@@ -54,7 +54,6 @@ class EventBus:
event (AstrMessageEvent): 事件对象
"""
event.trace.record("event_dispatch", config_name=conf_name)
# 如果有发送者名称: [平台名] 发送者名称/发送者ID: 消息概要
if event.get_sender_name():
logger.info(
+21 -3
View File
@@ -9,6 +9,7 @@ from astrbot.core.message.components import (
AtAll,
BaseMessageComponent,
Image,
Json,
Plain,
)
@@ -117,9 +118,26 @@ class MessageChain:
self.use_t2i_ = use_t2i
return self
def get_plain_text(self) -> str:
"""获取纯文本消息。这个方法将获取 chain 中所有 Plain 组件的文本并拼接成一条消息。空格分隔。"""
return " ".join([comp.text for comp in self.chain if isinstance(comp, Plain)])
def get_plain_text(self, with_other_comps_mark: bool = False) -> str:
"""获取纯文本消息。这个方法将获取 chain 中所有 Plain 组件的文本并拼接成一条消息。空格分隔。
Args:
with_other_comps_mark (bool): 是否在纯文本中标记其他组件的位置
"""
if not with_other_comps_mark:
return " ".join(
[comp.text for comp in self.chain if isinstance(comp, Plain)]
)
else:
texts = []
for comp in self.chain:
if isinstance(comp, Plain):
texts.append(comp.text)
elif isinstance(comp, Json):
texts.append(f"{comp.data}")
else:
texts.append(f"[{comp.__class__.__name__}]")
return " ".join(texts)
def squash_plain(self):
"""将消息链中的所有 Plain 消息段聚合到第一个 Plain 消息段中。"""
@@ -1,5 +1,7 @@
"""使用此功能应该先 pip install baidu-aip"""
from typing import Any, cast
from aip import AipContentCensor
from . import ContentSafetyStrategy
@@ -23,7 +25,8 @@ class BaiduAipStrategy(ContentSafetyStrategy):
count = len(res["data"])
parts = [f"百度审核服务发现 {count} 处违规:\n"]
for i in res["data"]:
parts.append(f"{i['msg']}\n")
# 百度 AIP 返回结构是动态 dict;类型检查时 i 可能被推断为序列,转成 dict 后用 get 取字段
parts.append(f"{cast(dict[str, Any], i).get('msg', '')}\n")
parts.append("\n判断结果:" + res["conclusion"])
info = "".join(parts)
return False, info
@@ -92,8 +92,13 @@ class InternalAgentSubStage(Stage):
"safety_mode_strategy", "system_prompt"
)
self.computer_use_runtime = settings.get("computer_use_runtime")
self.sandbox_cfg = settings.get("sandbox", {})
# Proactive capability configuration
proactive_cfg = settings.get("proactive_capability", {})
self.add_cron_tools = proactive_cfg.get("add_cron_tools", True)
self.conv_manager = ctx.plugin_manager.context.conversation_manager
self.main_agent_cfg = MainAgentBuildConfig(
@@ -112,7 +117,9 @@ class InternalAgentSubStage(Stage):
dequeue_context_length=self.dequeue_context_length,
llm_safety_mode=self.llm_safety_mode,
safety_mode_strategy=self.safety_mode_strategy,
computer_use_runtime=self.computer_use_runtime,
sandbox_cfg=self.sandbox_cfg,
add_cron_tools=self.add_cron_tools,
provider_settings=settings,
subagent_orchestrator=conf.get("subagent_orchestrator", {}),
timezone=self.ctx.plugin_manager.context.get_config().get("timezone"),
@@ -157,6 +164,7 @@ class InternalAgentSubStage(Stage):
event=event,
plugin_context=self.ctx.plugin_manager.context,
config=build_cfg,
apply_reset=False,
)
if build_result is None:
@@ -165,6 +173,7 @@ class InternalAgentSubStage(Stage):
agent_runner = build_result.agent_runner
req = build_result.provider_request
provider = build_result.provider
reset_coro = build_result.reset_coro
api_base = provider.provider_config.get("api_base", "")
for host in decoded_blocked:
@@ -183,6 +192,10 @@ class InternalAgentSubStage(Stage):
if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
return
# apply reset
if reset_coro:
await reset_coro
action_type = event.get_extra("action_type")
event.trace.record(
@@ -350,7 +363,8 @@ class InternalAgentSubStage(Stage):
token_usage = None
if runner_stats:
token_usage = runner_stats.token_usage.total
# token_usage = runner_stats.token_usage.total
token_usage = llm_response.usage.total if llm_response.usage else None
await self.conv_manager.update_conversation(
event.unified_msg_origin,
-2
View File
@@ -85,6 +85,4 @@ class PipelineScheduler:
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
await event.send(None)
event.trace.record("event_end")
logger.debug("pipeline 执行完毕。")
+5 -5
View File
@@ -8,6 +8,7 @@ from time import time
from typing import Any
from astrbot import logger
from astrbot.core.agent.tool import ToolSet
from astrbot.core.db.po import Conversation
from astrbot.core.message.components import (
At,
@@ -73,9 +74,6 @@ class AstrMessageEvent(abc.ABC):
self.span = self.trace
"""事件级 TraceSpan(别名: span)"""
self.trace.record("umo", umo=self.unified_msg_origin)
self.trace.record("event_created", created_at=self.created_at)
self._has_send_oper = False
"""在此次事件中是否有过至少一次发送消息的操作"""
self.call_llm = False
@@ -358,6 +356,7 @@ class AstrMessageEvent(abc.ABC):
self,
prompt: str,
func_tool_manager=None,
tool_set: ToolSet | None = None,
session_id: str = "",
image_urls: list[str] | None = None,
contexts: list | None = None,
@@ -380,7 +379,7 @@ class AstrMessageEvent(abc.ABC):
contexts: 当指定 contexts 将会使用 contexts 作为上下文如果同时传入了 conversation将会忽略 conversation
func_tool_manager: 函数工具管理器用于调用函数工具 self.context.get_llm_tool_manager() 获取
func_tool_manager: [Deprecated] 函数工具管理器用于调用函数工具 self.context.get_llm_tool_manager() 获取已过时请使用 tool_set 参数代替
conversation: 可选如果指定将在指定的对话中进行 LLM 请求对话的人格会被用于 LLM 请求并且结果将会被记录到对话中
@@ -396,7 +395,8 @@ class AstrMessageEvent(abc.ABC):
prompt=prompt,
session_id=session_id,
image_urls=image_urls,
func_tool=func_tool_manager,
# func_tool=func_tool_manager,
func_tool=tool_set,
contexts=contexts,
system_prompt=system_prompt,
conversation=conversation,
+2 -2
View File
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from astrbot.core.platform.message_type import MessageType
@@ -13,7 +13,7 @@ class MessageSession:
"""平台适配器实例的唯一标识符。自 AstrBot v4.0.0 起,该字段实际为 platform_id。"""
message_type: MessageType
session_id: str
platform_id: str | None = None
platform_id: str = field(init=False)
def __str__(self):
return f"{self.platform_id}:{self.message_type.value}:{self.session_id}"
@@ -21,3 +21,6 @@ class PlatformMetadata:
"""平台是否支持真实流式传输"""
support_proactive_message: bool = True
"""平台是否支持主动消息推送(非用户触发)"""
module_path: str | None = None
"""注册该适配器的模块路径,用于插件热重载时清理"""
+32
View File
@@ -37,6 +37,9 @@ def register_platform_adapter(
if "id" not in default_config_tmpl:
default_config_tmpl["id"] = adapter_name
# Get the module path of the class being decorated
module_path = cls.__module__
pm = PlatformMetadata(
name=adapter_name,
description=desc,
@@ -45,6 +48,7 @@ def register_platform_adapter(
adapter_display_name=adapter_display_name,
logo_path=logo_path,
support_streaming_message=support_streaming_message,
module_path=module_path,
)
platform_registry.append(pm)
platform_cls_map[adapter_name] = cls
@@ -52,3 +56,31 @@ def register_platform_adapter(
return cls
return decorator
def unregister_platform_adapters_by_module(module_path_prefix: str) -> list[str]:
"""根据模块路径前缀注销平台适配器。
在插件热重载时调用用于清理该插件注册的所有平台适配器
Args:
module_path_prefix: 模块路径前缀 "data.plugins.my_plugin"
Returns:
被注销的平台适配器名称列表
"""
unregistered = []
to_remove = []
for pm in platform_registry:
if pm.module_path and pm.module_path.startswith(module_path_prefix):
to_remove.append(pm)
unregistered.append(pm.name)
for pm in to_remove:
platform_registry.remove(pm)
if pm.name in platform_cls_map:
del platform_cls_map[pm.name]
logger.debug(f"平台适配器 {pm.name} 已注销 (来自模块 {pm.module_path})")
return unregistered
@@ -444,9 +444,20 @@ class DiscordPlatformAdapter(Platform):
logger.warning(f"[Discord] 指令 '{cmd_name}' defer 失败: {e}")
# 2. 构建 AstrBotMessage
channel = ctx.channel
abm = AstrBotMessage()
abm.type = self._get_message_type(ctx.channel, ctx.guild_id)
abm.group_id = self._get_channel_id(ctx.channel)
if channel is not None:
abm.type = self._get_message_type(channel, ctx.guild_id)
abm.group_id = self._get_channel_id(channel)
else:
# 防守式兜底:channel 取不到时,仍能根据 guild_id/channel_id 推断会话信息
abm.type = (
MessageType.GROUP_MESSAGE
if ctx.guild_id is not None
else MessageType.FRIEND_MESSAGE
)
abm.group_id = str(ctx.channel_id)
abm.message_str = message_str_for_filter
abm.sender = MessageMember(
user_id=str(ctx.author.id),
@@ -3,13 +3,10 @@ import base64
import json
import re
import time
import uuid
from typing import Any, cast
import lark_oapi as lark
from lark_oapi.api.im.v1 import (
CreateMessageRequest,
CreateMessageRequestBody,
GetMessageResourceRequest,
)
from lark_oapi.api.im.v1.processor import P2ImMessageReceiveV1Processor
@@ -125,44 +122,23 @@ class LarkPlatformAdapter(Platform):
session: MessageSesion,
message_chain: MessageChain,
):
if self.lark_api.im is None:
logger.error("[Lark] API Client im 模块未初始化,无法发送消息")
return
res = await LarkMessageEvent._convert_to_lark(message_chain, self.lark_api)
wrapped = {
"zh_cn": {
"title": "",
"content": res,
},
}
if session.message_type == MessageType.GROUP_MESSAGE:
id_type = "chat_id"
if "%" in session.session_id:
session.session_id = session.session_id.split("%")[1]
receive_id = session.session_id
if "%" in receive_id:
receive_id = receive_id.split("%")[1]
else:
id_type = "open_id"
receive_id = session.session_id
request = (
CreateMessageRequest.builder()
.receive_id_type(id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(session.session_id)
.content(json.dumps(wrapped))
.msg_type("post")
.uuid(str(uuid.uuid4()))
.build(),
)
.build()
# 复用 LarkMessageEvent 中的通用发送逻辑
await LarkMessageEvent.send_message_chain(
message_chain,
self.lark_api,
receive_id=receive_id,
receive_id_type=id_type,
)
response = await self.lark_api.im.v1.message.acreate(request)
if not response.success():
logger.error(f"发送飞书消息失败({response.code}): {response.msg}")
await super().send_by_session(session, message_chain)
def meta(self) -> PlatformMetadata:
+415 -28
View File
@@ -6,6 +6,8 @@ from io import BytesIO
import lark_oapi as lark
from lark_oapi.api.im.v1 import (
CreateFileRequest,
CreateFileRequestBody,
CreateImageRequest,
CreateImageRequestBody,
CreateMessageReactionRequest,
@@ -17,10 +19,15 @@ from lark_oapi.api.im.v1 import (
from astrbot import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import At, Plain
from astrbot.api.message_components import At, File, Plain, Record, Video
from astrbot.api.message_components import Image as AstrBotImage
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.media_utils import (
convert_audio_to_opus,
convert_video_format,
get_media_duration,
)
class LarkMessageEvent(AstrMessageEvent):
@@ -35,6 +42,144 @@ class LarkMessageEvent(AstrMessageEvent):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.bot = bot
@staticmethod
async def _send_im_message(
lark_client: lark.Client,
*,
content: str,
msg_type: str,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
) -> bool:
"""发送飞书 IM 消息的通用辅助函数
Args:
lark_client: 飞书客户端
content: 消息内容JSON字符串
msg_type: 消息类型post/file/audio/media等
reply_message_id: 回复的消息ID用于回复消息
receive_id: 接收者ID用于主动发送
receive_id_type: 接收者ID类型用于主动发送
Returns:
是否发送成功
"""
if lark_client.im is None:
logger.error("[Lark] API Client im 模块未初始化")
return False
if reply_message_id:
request = (
ReplyMessageRequest.builder()
.message_id(reply_message_id)
.request_body(
ReplyMessageRequestBody.builder()
.content(content)
.msg_type(msg_type)
.uuid(str(uuid.uuid4()))
.reply_in_thread(False)
.build()
)
.build()
)
response = await lark_client.im.v1.message.areply(request)
else:
from lark_oapi.api.im.v1 import (
CreateMessageRequest,
CreateMessageRequestBody,
)
if receive_id_type is None or receive_id is None:
logger.error(
"[Lark] 主动发送消息时,receive_id 和 receive_id_type 不能为空",
)
return False
request = (
CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(receive_id)
.content(content)
.msg_type(msg_type)
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
response = await lark_client.im.v1.message.acreate(request)
if not response.success():
logger.error(f"[Lark] 发送飞书消息失败({response.code}): {response.msg}")
return False
return True
@staticmethod
async def _upload_lark_file(
lark_client: lark.Client,
*,
path: str,
file_type: str,
duration: int | None = None,
) -> str | None:
"""上传文件到飞书的通用辅助函数
Args:
lark_client: 飞书客户端
path: 文件路径
file_type: 文件类型stream/opus/mp4等
duration: 媒体时长毫秒可选
Returns:
成功返回file_key失败返回None
"""
if not path or not os.path.exists(path):
logger.error(f"[Lark] 文件不存在: {path}")
return None
if lark_client.im is None:
logger.error("[Lark] API Client im 模块未初始化,无法上传文件")
return None
try:
with open(path, "rb") as file_obj:
body_builder = (
CreateFileRequestBody.builder()
.file_type(file_type)
.file_name(os.path.basename(path))
.file(file_obj)
)
if duration is not None:
body_builder.duration(duration)
request = (
CreateFileRequest.builder()
.request_body(body_builder.build())
.build()
)
response = await lark_client.im.v1.file.acreate(request)
if not response.success():
logger.error(
f"[Lark] 无法上传文件({response.code}): {response.msg}"
)
return None
if response.data is None:
logger.error("[Lark] 上传文件成功但未返回数据(data is None)")
return None
file_key = response.data.file_key
logger.debug(f"[Lark] 文件上传成功: {file_key}")
return file_key
except Exception as e:
logger.error(f"[Lark] 无法打开或上传文件: {e}")
return None
@staticmethod
async def _convert_to_lark(message: MessageChain, lark_client: lark.Client) -> list:
ret = []
@@ -103,6 +248,18 @@ class LarkMessageEvent(AstrMessageEvent):
ret.append(_stage)
ret.append([{"tag": "img", "image_key": image_key}])
_stage.clear()
elif isinstance(comp, File):
# 文件将通过 _send_file_message 方法单独发送,这里跳过
logger.debug("[Lark] 检测到文件组件,将单独发送")
continue
elif isinstance(comp, Record):
# 音频将通过 _send_audio_message 方法单独发送,这里跳过
logger.debug("[Lark] 检测到音频组件,将单独发送")
continue
elif isinstance(comp, Video):
# 视频将通过 _send_media_message 方法单独发送,这里跳过
logger.debug("[Lark] 检测到视频组件,将单独发送")
continue
else:
logger.warning(f"飞书 暂时不支持消息段: {comp.type}")
@@ -110,40 +267,270 @@ class LarkMessageEvent(AstrMessageEvent):
ret.append(_stage)
return ret
async def send(self, message: MessageChain):
res = await LarkMessageEvent._convert_to_lark(message, self.bot)
wrapped = {
"zh_cn": {
"title": "",
"content": res,
},
}
@staticmethod
async def send_message_chain(
message_chain: MessageChain,
lark_client: lark.Client,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
):
"""通用的消息链发送方法
request = (
ReplyMessageRequest.builder()
.message_id(self.message_obj.message_id)
.request_body(
ReplyMessageRequestBody.builder()
.content(json.dumps(wrapped))
.msg_type("post")
.uuid(str(uuid.uuid4()))
.reply_in_thread(False)
.build(),
)
.build()
)
if self.bot.im is None:
logger.error("[Lark] API Client im 模块未初始化,无法回复消息")
Args:
message_chain: 要发送的消息链
lark_client: 飞书客户端
reply_message_id: 回复的消息ID用于回复消息
receive_id: 接收者ID用于主动发送
receive_id_type: 接收者ID类型 'open_id', 'chat_id'用于主动发送
"""
if lark_client.im is None:
logger.error("[Lark] API Client im 模块未初始化")
return
response = await self.bot.im.v1.message.areply(request)
# 分离文件、音频、视频组件和其他组件
file_components: list[File] = []
audio_components: list[Record] = []
media_components: list[Video] = []
other_components = []
if not response.success():
logger.error(f"回复飞书消息失败({response.code}): {response.msg}")
for comp in message_chain.chain:
if isinstance(comp, File):
file_components.append(comp)
elif isinstance(comp, Record):
audio_components.append(comp)
elif isinstance(comp, Video):
media_components.append(comp)
else:
other_components.append(comp)
# 先发送非文件内容(如果有)
if other_components:
temp_chain = MessageChain()
temp_chain.chain = other_components
res = await LarkMessageEvent._convert_to_lark(temp_chain, lark_client)
if res: # 只在有内容时发送
wrapped = {
"zh_cn": {
"title": "",
"content": res,
},
}
await LarkMessageEvent._send_im_message(
lark_client,
content=json.dumps(wrapped),
msg_type="post",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
# 发送附件
for file_comp in file_components:
await LarkMessageEvent._send_file_message(
file_comp, lark_client, reply_message_id, receive_id, receive_id_type
)
for audio_comp in audio_components:
await LarkMessageEvent._send_audio_message(
audio_comp, lark_client, reply_message_id, receive_id, receive_id_type
)
for media_comp in media_components:
await LarkMessageEvent._send_media_message(
media_comp, lark_client, reply_message_id, receive_id, receive_id_type
)
async def send(self, message: MessageChain):
"""发送消息链到飞书,然后交给父类做框架级发送/记录"""
await LarkMessageEvent.send_message_chain(
message,
self.bot,
reply_message_id=self.message_obj.message_id,
)
await super().send(message)
@staticmethod
async def _send_file_message(
file_comp: File,
lark_client: lark.Client,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
):
"""发送文件消息
Args:
file_comp: 文件组件
lark_client: 飞书客户端
reply_message_id: 回复的消息ID用于回复消息
receive_id: 接收者ID用于主动发送
receive_id_type: 接收者ID类型用于主动发送
"""
file_path = file_comp.file or ""
file_key = await LarkMessageEvent._upload_lark_file(
lark_client, path=file_path, file_type="stream"
)
if not file_key:
return
content = json.dumps({"file_key": file_key})
await LarkMessageEvent._send_im_message(
lark_client,
content=content,
msg_type="file",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
@staticmethod
async def _send_audio_message(
audio_comp: Record,
lark_client: lark.Client,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
):
"""发送音频消息
Args:
audio_comp: 音频组件
lark_client: 飞书客户端
reply_message_id: 回复的消息ID用于回复消息
receive_id: 接收者ID用于主动发送
receive_id_type: 接收者ID类型用于主动发送
"""
# 获取音频文件路径
try:
original_audio_path = await audio_comp.convert_to_file_path()
except Exception as e:
logger.error(f"[Lark] 无法获取音频文件路径: {e}")
return
if not original_audio_path or not os.path.exists(original_audio_path):
logger.error(f"[Lark] 音频文件不存在: {original_audio_path}")
return
# 转换为opus格式
converted_audio_path = None
try:
audio_path = await convert_audio_to_opus(original_audio_path)
# 如果转换后路径与原路径不同,说明生成了新文件
if audio_path != original_audio_path:
converted_audio_path = audio_path
else:
audio_path = original_audio_path
except Exception as e:
logger.error(f"[Lark] 音频格式转换失败,将尝试直接上传: {e}")
# 如果转换失败,继续尝试直接上传原始文件
audio_path = original_audio_path
# 获取音频时长
duration = await get_media_duration(audio_path)
# 上传音频文件
file_key = await LarkMessageEvent._upload_lark_file(
lark_client,
path=audio_path,
file_type="opus",
duration=duration,
)
# 清理转换后的临时音频文件
if converted_audio_path and os.path.exists(converted_audio_path):
try:
os.remove(converted_audio_path)
logger.debug(f"[Lark] 已删除转换后的音频文件: {converted_audio_path}")
except Exception as e:
logger.warning(f"[Lark] 删除转换后的音频文件失败: {e}")
if not file_key:
return
await LarkMessageEvent._send_im_message(
lark_client,
content=json.dumps({"file_key": file_key}),
msg_type="audio",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
@staticmethod
async def _send_media_message(
media_comp: Video,
lark_client: lark.Client,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
):
"""发送视频消息
Args:
media_comp: 视频组件
lark_client: 飞书客户端
reply_message_id: 回复的消息ID用于回复消息
receive_id: 接收者ID用于主动发送
receive_id_type: 接收者ID类型用于主动发送
"""
# 获取视频文件路径
try:
original_video_path = await media_comp.convert_to_file_path()
except Exception as e:
logger.error(f"[Lark] 无法获取视频文件路径: {e}")
return
if not original_video_path or not os.path.exists(original_video_path):
logger.error(f"[Lark] 视频文件不存在: {original_video_path}")
return
# 转换为mp4格式
converted_video_path = None
try:
video_path = await convert_video_format(original_video_path, "mp4")
# 如果转换后路径与原路径不同,说明生成了新文件
if video_path != original_video_path:
converted_video_path = video_path
else:
video_path = original_video_path
except Exception as e:
logger.error(f"[Lark] 视频格式转换失败,将尝试直接上传: {e}")
# 如果转换失败,继续尝试直接上传原始文件
video_path = original_video_path
# 获取视频时长
duration = await get_media_duration(video_path)
# 上传视频文件
file_key = await LarkMessageEvent._upload_lark_file(
lark_client,
path=video_path,
file_type="mp4",
duration=duration,
)
# 清理转换后的临时视频文件
if converted_video_path and os.path.exists(converted_video_path):
try:
os.remove(converted_video_path)
logger.debug(f"[Lark] 已删除转换后的视频文件: {converted_video_path}")
except Exception as e:
logger.warning(f"[Lark] 删除转换后的视频文件失败: {e}")
if not file_key:
return
await LarkMessageEvent._send_im_message(
lark_client,
content=json.dumps({"file_key": file_key}),
msg_type="media",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
async def react(self, emoji: str):
if self.bot.im is None:
logger.error("[Lark] API Client im 模块未初始化,无法发送表情")
@@ -29,43 +29,11 @@ class QueueListener:
def __init__(self, webchat_queue_mgr: WebChatQueueMgr, callback: Callable) -> None:
self.webchat_queue_mgr = webchat_queue_mgr
self.callback = callback
self.running_tasks = set()
async def listen_to_queue(self, conversation_id: str):
"""Listen to a specific conversation queue"""
queue = self.webchat_queue_mgr.get_or_create_queue(conversation_id)
while True:
try:
data = await queue.get()
await self.callback(data)
except Exception as e:
logger.error(
f"Error processing message from conversation {conversation_id}: {e}",
)
break
async def run(self):
"""Monitor for new conversation queues and start listeners"""
monitored_conversations = set()
while True:
# Check for new conversations
current_conversations = set(self.webchat_queue_mgr.queues.keys())
new_conversations = current_conversations - monitored_conversations
# Start listeners for new conversations
for conversation_id in new_conversations:
task = asyncio.create_task(self.listen_to_queue(conversation_id))
self.running_tasks.add(task)
task.add_done_callback(self.running_tasks.discard)
monitored_conversations.add(conversation_id)
logger.debug(f"Started listener for conversation: {conversation_id}")
# Clean up monitored conversations that no longer exist
removed_conversations = monitored_conversations - current_conversations
monitored_conversations -= removed_conversations
await asyncio.sleep(1) # Check for new conversations every second
"""Register callback and keep adapter task alive."""
self.webchat_queue_mgr.set_listener(self.callback)
await asyncio.Event().wait()
@register_platform_adapter("webchat", "webchat")
@@ -26,8 +26,12 @@ class WebChatMessageEvent(AstrMessageEvent):
session_id: str,
streaming: bool = False,
) -> str | None:
cid = session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
request_id = str(message_id)
conversation_id = session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(
request_id,
conversation_id,
)
if not message:
await web_chat_back_queue.put(
{
@@ -124,9 +128,13 @@ class WebChatMessageEvent(AstrMessageEvent):
async def send_streaming(self, generator, use_fallback: bool = False):
final_data = ""
reasoning_content = ""
cid = self.session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
message_id = self.message_obj.message_id
request_id = str(message_id)
conversation_id = self.session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(
request_id,
conversation_id,
)
async for chain in generator:
# 处理音频流(Live Mode
if chain.type == "audio_chunk":
@@ -1,35 +1,147 @@
import asyncio
from collections.abc import Awaitable, Callable
from astrbot import logger
class WebChatQueueMgr:
def __init__(self) -> None:
self.queues = {}
def __init__(self, queue_maxsize: int = 128, back_queue_maxsize: int = 512) -> None:
self.queues: dict[str, asyncio.Queue] = {}
"""Conversation ID to asyncio.Queue mapping"""
self.back_queues = {}
"""Conversation ID to asyncio.Queue mapping for responses"""
self.back_queues: dict[str, asyncio.Queue] = {}
"""Request ID to asyncio.Queue mapping for responses"""
self._conversation_back_requests: dict[str, set[str]] = {}
self._request_conversation: dict[str, str] = {}
self._queue_close_events: dict[str, asyncio.Event] = {}
self._listener_tasks: dict[str, asyncio.Task] = {}
self._listener_callback: Callable[[tuple], Awaitable[None]] | None = None
self.queue_maxsize = queue_maxsize
self.back_queue_maxsize = back_queue_maxsize
def get_or_create_queue(self, conversation_id: str) -> asyncio.Queue:
"""Get or create a queue for the given conversation ID"""
if conversation_id not in self.queues:
self.queues[conversation_id] = asyncio.Queue()
self.queues[conversation_id] = asyncio.Queue(maxsize=self.queue_maxsize)
self._queue_close_events[conversation_id] = asyncio.Event()
self._start_listener_if_needed(conversation_id)
return self.queues[conversation_id]
def get_or_create_back_queue(self, conversation_id: str) -> asyncio.Queue:
"""Get or create a back queue for the given conversation ID"""
if conversation_id not in self.back_queues:
self.back_queues[conversation_id] = asyncio.Queue()
return self.back_queues[conversation_id]
def get_or_create_back_queue(
self,
request_id: str,
conversation_id: str | None = None,
) -> asyncio.Queue:
"""Get or create a back queue for the given request ID"""
if request_id not in self.back_queues:
self.back_queues[request_id] = asyncio.Queue(
maxsize=self.back_queue_maxsize
)
if conversation_id:
self._request_conversation[request_id] = conversation_id
if conversation_id not in self._conversation_back_requests:
self._conversation_back_requests[conversation_id] = set()
self._conversation_back_requests[conversation_id].add(request_id)
return self.back_queues[request_id]
def remove_back_queue(self, request_id: str):
"""Remove back queue for the given request ID"""
self.back_queues.pop(request_id, None)
conversation_id = self._request_conversation.pop(request_id, None)
if conversation_id:
request_ids = self._conversation_back_requests.get(conversation_id)
if request_ids is not None:
request_ids.discard(request_id)
if not request_ids:
self._conversation_back_requests.pop(conversation_id, None)
def remove_queues(self, conversation_id: str):
"""Remove queues for the given conversation ID"""
if conversation_id in self.queues:
del self.queues[conversation_id]
if conversation_id in self.back_queues:
del self.back_queues[conversation_id]
for request_id in list(
self._conversation_back_requests.get(conversation_id, set())
):
self.remove_back_queue(request_id)
self._conversation_back_requests.pop(conversation_id, None)
self.remove_queue(conversation_id)
def remove_queue(self, conversation_id: str):
"""Remove input queue and listener for the given conversation ID"""
self.queues.pop(conversation_id, None)
close_event = self._queue_close_events.pop(conversation_id, None)
if close_event is not None:
close_event.set()
task = self._listener_tasks.pop(conversation_id, None)
if task is not None:
task.cancel()
def has_queue(self, conversation_id: str) -> bool:
"""Check if a queue exists for the given conversation ID"""
return conversation_id in self.queues
def set_listener(
self,
callback: Callable[[tuple], Awaitable[None]],
):
self._listener_callback = callback
for conversation_id in list(self.queues.keys()):
self._start_listener_if_needed(conversation_id)
def _start_listener_if_needed(self, conversation_id: str):
if self._listener_callback is None:
return
if conversation_id in self._listener_tasks:
task = self._listener_tasks[conversation_id]
if not task.done():
return
queue = self.queues.get(conversation_id)
close_event = self._queue_close_events.get(conversation_id)
if queue is None or close_event is None:
return
task = asyncio.create_task(
self._listen_to_queue(conversation_id, queue, close_event),
name=f"webchat_listener_{conversation_id}",
)
self._listener_tasks[conversation_id] = task
task.add_done_callback(
lambda _: self._listener_tasks.pop(conversation_id, None)
)
logger.debug(f"Started listener for conversation: {conversation_id}")
async def _listen_to_queue(
self,
conversation_id: str,
queue: asyncio.Queue,
close_event: asyncio.Event,
):
while True:
get_task = asyncio.create_task(queue.get())
close_task = asyncio.create_task(close_event.wait())
try:
done, pending = await asyncio.wait(
{get_task, close_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
if close_task in done:
break
data = get_task.result()
if self._listener_callback is None:
continue
try:
await self._listener_callback(data)
except Exception as e:
logger.error(
f"Error processing message from conversation {conversation_id}: {e}"
)
except asyncio.CancelledError:
break
finally:
if not get_task.done():
get_task.cancel()
if not close_task.done():
close_task.cancel()
webchat_queue_mgr = WebChatQueueMgr()
@@ -51,44 +51,13 @@ class WecomAIQueueListener:
) -> None:
self.queue_mgr = queue_mgr
self.callback = callback
self.running_tasks = set()
async def listen_to_queue(self, session_id: str):
"""监听特定会话的队列"""
queue = self.queue_mgr.get_or_create_queue(session_id)
while True:
try:
data = await queue.get()
await self.callback(data)
except Exception as e:
logger.error(f"处理会话 {session_id} 消息时发生错误: {e}")
break
async def run(self):
"""监控新会话队列并启动监听器"""
monitored_sessions = set()
"""注册监听回调并定期清理过期响应。"""
self.queue_mgr.set_listener(self.callback)
while True:
# 检查新会话
current_sessions = set(self.queue_mgr.queues.keys())
new_sessions = current_sessions - monitored_sessions
# 为新会话启动监听器
for session_id in new_sessions:
task = asyncio.create_task(self.listen_to_queue(session_id))
self.running_tasks.add(task)
task.add_done_callback(self.running_tasks.discard)
monitored_sessions.add(session_id)
logger.debug(f"[WecomAI] 为会话启动监听器: {session_id}")
# 清理已不存在的会话
removed_sessions = monitored_sessions - current_sessions
monitored_sessions -= removed_sessions
# 清理过期的待处理响应
self.queue_mgr.cleanup_expired_responses()
await asyncio.sleep(1) # 每秒检查一次新会话
await asyncio.sleep(1)
@register_platform_adapter(
@@ -212,7 +181,12 @@ class WecomAIBotAdapter(Platform):
# wechat server is requesting for updates of a stream
stream_id = message_data["stream"]["id"]
if not self.queue_mgr.has_back_queue(stream_id):
logger.error(f"Cannot find back queue for stream_id: {stream_id}")
if self.queue_mgr.is_stream_finished(stream_id):
logger.debug(
f"Stream already finished, returning end message: {stream_id}"
)
else:
logger.warning(f"Cannot find back queue for stream_id: {stream_id}")
# 返回结束标志,告诉微信服务器流已结束
end_message = WecomAIBotStreamMessageBuilder.make_text_stream(
@@ -243,10 +217,10 @@ class WecomAIBotAdapter(Platform):
latest_plain_content = msg["data"] or ""
elif msg["type"] == "image":
image_base64.append(msg["image_data"])
elif msg["type"] == "end":
elif msg["type"] in {"end", "complete"}:
# stream end
finish = True
self.queue_mgr.remove_queues(stream_id)
self.queue_mgr.remove_queues(stream_id, mark_finished=True)
break
logger.debug(
@@ -4,6 +4,7 @@
"""
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any
from astrbot.api import logger
@@ -12,7 +13,7 @@ from astrbot.api import logger
class WecomAIQueueMgr:
"""企业微信智能机器人队列管理器"""
def __init__(self) -> None:
def __init__(self, queue_maxsize: int = 128, back_queue_maxsize: int = 512) -> None:
self.queues: dict[str, asyncio.Queue] = {}
"""StreamID 到输入队列的映射 - 用于接收用户消息"""
@@ -21,6 +22,13 @@ class WecomAIQueueMgr:
self.pending_responses: dict[str, dict[str, Any]] = {}
"""待处理的响应缓存,用于流式响应"""
self.completed_streams: dict[str, float] = {}
"""已结束的 stream 缓存,用于兼容平台后续重复轮询"""
self._queue_close_events: dict[str, asyncio.Event] = {}
self._listener_tasks: dict[str, asyncio.Task] = {}
self._listener_callback: Callable[[dict], Awaitable[None]] | None = None
self.queue_maxsize = queue_maxsize
self.back_queue_maxsize = back_queue_maxsize
def get_or_create_queue(self, session_id: str) -> asyncio.Queue:
"""获取或创建指定会话的输入队列
@@ -33,7 +41,9 @@ class WecomAIQueueMgr:
"""
if session_id not in self.queues:
self.queues[session_id] = asyncio.Queue()
self.queues[session_id] = asyncio.Queue(maxsize=self.queue_maxsize)
self._queue_close_events[session_id] = asyncio.Event()
self._start_listener_if_needed(session_id)
logger.debug(f"[WecomAI] 创建输入队列: {session_id}")
return self.queues[session_id]
@@ -48,20 +58,21 @@ class WecomAIQueueMgr:
"""
if session_id not in self.back_queues:
self.back_queues[session_id] = asyncio.Queue()
self.back_queues[session_id] = asyncio.Queue(
maxsize=self.back_queue_maxsize
)
logger.debug(f"[WecomAI] 创建输出队列: {session_id}")
return self.back_queues[session_id]
def remove_queues(self, session_id: str):
def remove_queues(self, session_id: str, mark_finished: bool = False):
"""移除指定会话的所有队列
Args:
session_id: 会话ID
mark_finished: 是否标记为已正常结束
"""
if session_id in self.queues:
del self.queues[session_id]
logger.debug(f"[WecomAI] 移除输入队列: {session_id}")
self.remove_queue(session_id)
if session_id in self.back_queues:
del self.back_queues[session_id]
@@ -70,6 +81,23 @@ class WecomAIQueueMgr:
if session_id in self.pending_responses:
del self.pending_responses[session_id]
logger.debug(f"[WecomAI] 移除待处理响应: {session_id}")
if mark_finished:
self.completed_streams[session_id] = asyncio.get_event_loop().time()
logger.debug(f"[WecomAI] 标记流已结束: {session_id}")
def remove_queue(self, session_id: str):
"""仅移除输入队列和对应监听任务"""
if session_id in self.queues:
del self.queues[session_id]
logger.debug(f"[WecomAI] 移除输入队列: {session_id}")
close_event = self._queue_close_events.pop(session_id, None)
if close_event is not None:
close_event.set()
task = self._listener_tasks.pop(session_id, None)
if task is not None:
task.cancel()
def has_queue(self, session_id: str) -> bool:
"""检查是否存在指定会话的队列
@@ -121,6 +149,20 @@ class WecomAIQueueMgr:
"""
return self.pending_responses.get(session_id)
def is_stream_finished(
self,
session_id: str,
max_age_seconds: int = 60,
) -> bool:
"""判断 stream 是否在短期内已结束"""
finished_at = self.completed_streams.get(session_id)
if finished_at is None:
return False
if asyncio.get_event_loop().time() - finished_at > max_age_seconds:
self.completed_streams.pop(session_id, None)
return False
return True
def cleanup_expired_responses(self, max_age_seconds: int = 300):
"""清理过期的待处理响应
@@ -136,8 +178,75 @@ class WecomAIQueueMgr:
expired_sessions.append(session_id)
for session_id in expired_sessions:
del self.pending_responses[session_id]
logger.debug(f"[WecomAI] 清理过期响应: {session_id}")
self.remove_queues(session_id)
logger.debug(f"[WecomAI] 清理过期响应及队列: {session_id}")
expired_finished = [
session_id
for session_id, finished_at in self.completed_streams.items()
if current_time - finished_at > 60
]
for session_id in expired_finished:
self.completed_streams.pop(session_id, None)
def set_listener(
self,
callback: Callable[[dict], Awaitable[None]],
):
self._listener_callback = callback
for session_id in list(self.queues.keys()):
self._start_listener_if_needed(session_id)
def _start_listener_if_needed(self, session_id: str):
if self._listener_callback is None:
return
if session_id in self._listener_tasks:
task = self._listener_tasks[session_id]
if not task.done():
return
queue = self.queues.get(session_id)
close_event = self._queue_close_events.get(session_id)
if queue is None or close_event is None:
return
task = asyncio.create_task(
self._listen_to_queue(session_id, queue, close_event),
name=f"wecomai_listener_{session_id}",
)
self._listener_tasks[session_id] = task
task.add_done_callback(lambda _: self._listener_tasks.pop(session_id, None))
logger.debug(f"[WecomAI] 为会话启动监听器: {session_id}")
async def _listen_to_queue(
self,
session_id: str,
queue: asyncio.Queue,
close_event: asyncio.Event,
):
while True:
get_task = asyncio.create_task(queue.get())
close_task = asyncio.create_task(close_event.wait())
try:
done, pending = await asyncio.wait(
{get_task, close_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
if close_task in done:
break
data = get_task.result()
if self._listener_callback is None:
continue
try:
await self._listener_callback(data)
except Exception as e:
logger.error(f"处理会话 {session_id} 消息时发生错误: {e}")
except asyncio.CancelledError:
break
finally:
if not get_task.done():
get_task.cancel()
if not close_task.done():
close_task.cancel()
def get_stats(self) -> dict[str, int]:
"""获取队列统计信息
@@ -63,7 +63,7 @@ class ProviderFishAudioTTSAPI(TTSProvider):
self.headers = {
"Authorization": f"Bearer {self.chosen_api_key}",
}
self.set_model(provider_config.get("model", None))
self.set_model(provider_config.get("model", ""))
async def _get_reference_id_by_character(self, character: str) -> str | None:
"""获取角色的reference_id
+3 -3
View File
@@ -37,9 +37,9 @@ class CustomFilter(HandlerFilter, metaclass=CustomFilterMeta):
class CustomFilterOr(CustomFilter):
def __init__(self, filter1: CustomFilter, filter2: CustomFilter):
super().__init__()
if not isinstance(filter1, CustomFilter | CustomFilterAnd | CustomFilterOr):
if not isinstance(filter1, (CustomFilter, CustomFilterAnd, CustomFilterOr)):
raise ValueError(
"CustomFilter lass can only operate with other CustomFilter.",
"CustomFilter class can only operate with other CustomFilter.",
)
self.filter1 = filter1
self.filter2 = filter2
@@ -51,7 +51,7 @@ class CustomFilterOr(CustomFilter):
class CustomFilterAnd(CustomFilter):
def __init__(self, filter1: CustomFilter, filter2: CustomFilter):
super().__init__()
if not isinstance(filter1, CustomFilter | CustomFilterAnd | CustomFilterOr):
if not isinstance(filter1, (CustomFilter, CustomFilterAnd, CustomFilterOr)):
raise ValueError(
"CustomFilter lass can only operate with other CustomFilter.",
)
+1 -1
View File
@@ -150,7 +150,7 @@ def register_custom_filter(custom_type_filter, *args, **kwargs):
if args:
raise_error = args[0]
if not isinstance(custom_filter, CustomFilterAnd | CustomFilterOr):
if not isinstance(custom_filter, (CustomFilterAnd, CustomFilterOr)):
custom_filter = custom_filter(raise_error)
def decorator(awaitable):
+13
View File
@@ -15,6 +15,7 @@ import yaml
from astrbot.core import logger, pip_installer, sp
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.platform.register import unregister_platform_adapters_by_module
from astrbot.core.provider.register import llm_tools
from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
@@ -842,6 +843,18 @@ class PluginManager:
for func_tool in to_remove:
llm_tools.func_list.remove(func_tool)
# Unregister platform adapters registered by this plugin
# module_path is like "data.plugins.my_plugin.main", extract prefix like "data.plugins.my_plugin"
module_prefix = ".".join(plugin_module_path.split(".")[:-1])
if module_prefix:
unregistered_adapters = unregister_platform_adapters_by_module(
module_prefix
)
for adapter_name in unregistered_adapters:
logger.info(
f"移除了插件 {plugin_name} 的平台适配器 {adapter_name}",
)
if plugin is None:
return
+207
View File
@@ -0,0 +1,207 @@
"""媒体文件处理工具
提供音视频格式转换时长获取等功能
"""
import asyncio
import os
import subprocess
import uuid
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
async def get_media_duration(file_path: str) -> int | None:
"""使用ffprobe获取媒体文件时长
Args:
file_path: 媒体文件路径
Returns:
时长毫秒如果获取失败返回None
"""
try:
# 使用ffprobe获取时长
process = await asyncio.create_subprocess_exec(
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
file_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode == 0 and stdout:
duration_seconds = float(stdout.decode().strip())
duration_ms = int(duration_seconds * 1000)
logger.debug(f"[Media Utils] 获取媒体时长: {duration_ms}ms")
return duration_ms
else:
logger.warning(f"[Media Utils] 无法获取媒体文件时长: {file_path}")
return None
except FileNotFoundError:
logger.warning(
"[Media Utils] ffprobe未安装或不在PATH中,无法获取媒体时长。请安装ffmpeg: https://ffmpeg.org/"
)
return None
except Exception as e:
logger.warning(f"[Media Utils] 获取媒体时长时出错: {e}")
return None
async def convert_audio_to_opus(audio_path: str, output_path: str | None = None) -> str:
"""使用ffmpeg将音频转换为opus格式
Args:
audio_path: 原始音频文件路径
output_path: 输出文件路径如果为None则自动生成
Returns:
转换后的opus文件路径
Raises:
Exception: 转换失败时抛出异常
"""
# 如果已经是opus格式,直接返回
if audio_path.lower().endswith(".opus"):
return audio_path
# 生成输出文件路径
if output_path is None:
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(temp_dir, exist_ok=True)
output_path = os.path.join(temp_dir, f"{uuid.uuid4()}.opus")
try:
# 使用ffmpeg转换为opus格式
# -y: 覆盖输出文件
# -i: 输入文件
# -acodec libopus: 使用opus编码器
# -ac 1: 单声道
# -ar 16000: 采样率16kHz
process = await asyncio.create_subprocess_exec(
"ffmpeg",
"-y",
"-i",
audio_path,
"-acodec",
"libopus",
"-ac",
"1",
"-ar",
"16000",
output_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
# 清理可能已生成但无效的临时文件
if output_path and os.path.exists(output_path):
try:
os.remove(output_path)
logger.debug(
f"[Media Utils] 已清理失败的opus输出文件: {output_path}"
)
except OSError as e:
logger.warning(f"[Media Utils] 清理失败的opus输出文件时出错: {e}")
error_msg = stderr.decode() if stderr else "未知错误"
logger.error(f"[Media Utils] ffmpeg转换音频失败: {error_msg}")
raise Exception(f"ffmpeg conversion failed: {error_msg}")
logger.debug(f"[Media Utils] 音频转换成功: {audio_path} -> {output_path}")
return output_path
except FileNotFoundError:
logger.error(
"[Media Utils] ffmpeg未安装或不在PATH中,无法转换音频格式。请安装ffmpeg: https://ffmpeg.org/"
)
raise Exception("ffmpeg not found")
except Exception as e:
logger.error(f"[Media Utils] 转换音频格式时出错: {e}")
raise
async def convert_video_format(
video_path: str, output_format: str = "mp4", output_path: str | None = None
) -> str:
"""使用ffmpeg转换视频格式
Args:
video_path: 原始视频文件路径
output_format: 目标格式默认mp4
output_path: 输出文件路径如果为None则自动生成
Returns:
转换后的视频文件路径
Raises:
Exception: 转换失败时抛出异常
"""
# 如果已经是目标格式,直接返回
if video_path.lower().endswith(f".{output_format}"):
return video_path
# 生成输出文件路径
if output_path is None:
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(temp_dir, exist_ok=True)
output_path = os.path.join(temp_dir, f"{uuid.uuid4()}.{output_format}")
try:
# 使用ffmpeg转换视频格式
process = await asyncio.create_subprocess_exec(
"ffmpeg",
"-y",
"-i",
video_path,
"-c:v",
"libx264",
"-c:a",
"aac",
output_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
# 清理可能已生成但无效的临时文件
if output_path and os.path.exists(output_path):
try:
os.remove(output_path)
logger.debug(
f"[Media Utils] 已清理失败的{output_format}输出文件: {output_path}"
)
except OSError as e:
logger.warning(
f"[Media Utils] 清理失败的{output_format}输出文件时出错: {e}"
)
error_msg = stderr.decode() if stderr else "未知错误"
logger.error(f"[Media Utils] ffmpeg转换视频失败: {error_msg}")
raise Exception(f"ffmpeg conversion failed: {error_msg}")
logger.debug(f"[Media Utils] 视频转换成功: {video_path} -> {output_path}")
return output_path
except FileNotFoundError:
logger.error(
"[Media Utils] ffmpeg未安装或不在PATH中,无法转换视频格式。请安装ffmpeg: https://ffmpeg.org/"
)
raise Exception("ffmpeg not found")
except Exception as e:
logger.error(f"[Media Utils] 转换视频格式时出错: {e}")
raise
+2 -2
View File
@@ -23,7 +23,7 @@ class SharedPreferences:
)
self.path = json_storage_path
self.db_helper = db_helper
self.temorary_cache: dict[str, dict[str, Any]] = defaultdict(dict)
self.temporary_cache: dict[str, dict[str, Any]] = defaultdict(dict)
"""automatically clear per 24 hours. Might be helpful in some cases XD"""
self._sync_loop = asyncio.new_event_loop()
@@ -37,7 +37,7 @@ class SharedPreferences:
self._scheduler.start()
def _clear_temporary_cache(self):
self.temorary_cache.clear()
self.temporary_cache.clear()
async def get_async(
self,
+4
View File
@@ -50,6 +50,10 @@ class TraceSpan:
self.started_at = time.time()
def record(self, action: str, **fields: Any) -> None:
# Check if trace recording is enabled
if not astrbot_config.get("trace_enable", True):
return
payload = {
"type": "trace",
"level": "TRACE",
+9 -3
View File
@@ -238,6 +238,7 @@ class ChatRoute(Route):
Returns:
包含 used 列表的字典记录被引用的搜索结果
"""
supported = ["web_search_tavily", "web_search_bocha"]
# 从 accumulated_parts 中找到所有 web_search_tavily 的工具调用结果
web_search_results = {}
tool_call_parts = [
@@ -248,7 +249,7 @@ class ChatRoute(Route):
for part in tool_call_parts:
for tool_call in part["tool_calls"]:
if tool_call.get("name") != "web_search_tavily" or not tool_call.get(
if tool_call.get("name") not in supported or not tool_call.get(
"result"
):
continue
@@ -278,7 +279,7 @@ class ChatRoute(Route):
if ref_index not in web_search_results:
continue
payload = {"index": ref_index, **web_search_results[ref_index]}
if favicon := sp.temorary_cache.get("_ws_favicon", {}).get(payload["url"]):
if favicon := sp.temporary_cache.get("_ws_favicon", {}).get(payload["url"]):
payload["favicon"] = favicon
used_refs.append(payload)
@@ -353,12 +354,15 @@ class ChatRoute(Route):
return Response().error("session_id is empty").__dict__
webchat_conv_id = session_id
back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id)
# 构建用户消息段(包含 path 用于传递给 adapter
message_parts = await self._build_user_message_parts(message)
message_id = str(uuid.uuid4())
back_queue = webchat_queue_mgr.get_or_create_back_queue(
message_id,
webchat_conv_id,
)
async def stream():
client_disconnected = False
@@ -531,6 +535,8 @@ class ChatRoute(Route):
refs = {}
except BaseException as e:
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
finally:
webchat_queue_mgr.remove_back_queue(message_id)
# 将消息放入会话特定的队列
chat_queue = webchat_queue_mgr.get_or_create_queue(webchat_conv_id)
+1 -1
View File
@@ -23,7 +23,7 @@ class CronRoute(Route):
]
self.register_routes()
def _serialize_job(self, job):
def _serialize_job(self, job) -> dict:
data = job.model_dump() if hasattr(job, "model_dump") else job.__dict__
for k in ["created_at", "updated_at", "last_run_at", "next_run_time"]:
if isinstance(data.get(k), datetime):
+2 -1
View File
@@ -4,6 +4,7 @@ import asyncio
import os
import traceback
import uuid
from typing import Any
import aiofiles
from quart import request
@@ -75,7 +76,7 @@ class KnowledgeBaseRoute(Route):
}
def _set_task_result(
self, task_id: str, status: str, result: any = None, error: str | None = None
self, task_id: str, status: str, result: Any = None, error: str | None = None
) -> None:
self.upload_tasks[task_id] = {
"status": status,
+127 -122
View File
@@ -256,143 +256,148 @@ class LiveChatRoute(Route):
await queue.put((session.username, cid, payload))
# 3. 等待响应并流式发送 TTS 音频
back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, cid)
bot_text = ""
audio_playing = False
while True:
if session.should_interrupt:
# 用户打断,停止处理
logger.info("[Live Chat] 检测到用户打断")
await websocket.send_json({"t": "stop_play"})
# 保存消息并标记为被打断
await self._save_interrupted_message(session, user_text, bot_text)
# 清空队列中未处理的消息
while not back_queue.empty():
try:
while True:
if session.should_interrupt:
# 用户打断,停止处理
logger.info("[Live Chat] 检测到用户打断")
await websocket.send_json({"t": "stop_play"})
# 保存消息并标记为被打断
await self._save_interrupted_message(
session, user_text, bot_text
)
# 清空队列中未处理的消息
while not back_queue.empty():
try:
back_queue.get_nowait()
except asyncio.QueueEmpty:
break
break
try:
result = await asyncio.wait_for(back_queue.get(), timeout=0.5)
except asyncio.TimeoutError:
continue
if not result:
continue
result_message_id = result.get("message_id")
if result_message_id != message_id:
logger.warning(
f"[Live Chat] 消息 ID 不匹配: {result_message_id} != {message_id}"
)
continue
result_type = result.get("type")
result_chain_type = result.get("chain_type")
data = result.get("data", "")
if result_chain_type == "agent_stats":
try:
back_queue.get_nowait()
except asyncio.QueueEmpty:
break
break
stats = json.loads(data)
await websocket.send_json(
{
"t": "metrics",
"data": {
"llm_ttft": stats.get("time_to_first_token", 0),
"llm_total_time": stats.get("end_time", 0)
- stats.get("start_time", 0),
},
}
)
except Exception as e:
logger.error(f"[Live Chat] 解析 AgentStats 失败: {e}")
continue
try:
result = await asyncio.wait_for(back_queue.get(), timeout=0.5)
except asyncio.TimeoutError:
continue
if result_chain_type == "tts_stats":
try:
stats = json.loads(data)
await websocket.send_json(
{
"t": "metrics",
"data": stats,
}
)
except Exception as e:
logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}")
continue
if not result:
continue
if result_type == "plain":
# 普通文本消息
bot_text += data
result_message_id = result.get("message_id")
if result_message_id != message_id:
logger.warning(
f"[Live Chat] 消息 ID 不匹配: {result_message_id} != {message_id}"
)
continue
elif result_type == "audio_chunk":
# 流式音频数据
if not audio_playing:
audio_playing = True
logger.debug("[Live Chat] 开始播放音频流")
result_type = result.get("type")
result_chain_type = result.get("chain_type")
data = result.get("data", "")
# Calculate latency from wav assembly finish to first audio chunk
speak_to_first_frame_latency = (
time.time() - wav_assembly_finish_time
)
await websocket.send_json(
{
"t": "metrics",
"data": {
"speak_to_first_frame": speak_to_first_frame_latency
},
}
)
if result_chain_type == "agent_stats":
try:
stats = json.loads(data)
text = result.get("text")
if text:
await websocket.send_json(
{
"t": "bot_text_chunk",
"data": {"text": text},
}
)
# 发送音频数据给前端
await websocket.send_json(
{
"t": "response",
"data": data, # base64 编码的音频数据
}
)
elif result_type in ["complete", "end"]:
# 处理完成
logger.info(f"[Live Chat] Bot 回复完成: {bot_text}")
# 如果没有音频流,发送 bot 消息文本
if not audio_playing:
await websocket.send_json(
{
"t": "bot_msg",
"data": {
"text": bot_text,
"ts": int(time.time() * 1000),
},
}
)
# 发送结束标记
await websocket.send_json({"t": "end"})
# 发送总耗时
wav_to_tts_duration = time.time() - wav_assembly_finish_time
await websocket.send_json(
{
"t": "metrics",
"data": {
"llm_ttft": stats.get("time_to_first_token", 0),
"llm_total_time": stats.get("end_time", 0)
- stats.get("start_time", 0),
},
"data": {"wav_to_tts_total_time": wav_to_tts_duration},
}
)
except Exception as e:
logger.error(f"[Live Chat] 解析 AgentStats 失败: {e}")
continue
if result_chain_type == "tts_stats":
try:
stats = json.loads(data)
await websocket.send_json(
{
"t": "metrics",
"data": stats,
}
)
except Exception as e:
logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}")
continue
if result_type == "plain":
# 普通文本消息
bot_text += data
elif result_type == "audio_chunk":
# 流式音频数据
if not audio_playing:
audio_playing = True
logger.debug("[Live Chat] 开始播放音频流")
# Calculate latency from wav assembly finish to first audio chunk
speak_to_first_frame_latency = (
time.time() - wav_assembly_finish_time
)
await websocket.send_json(
{
"t": "metrics",
"data": {
"speak_to_first_frame": speak_to_first_frame_latency
},
}
)
text = result.get("text")
if text:
await websocket.send_json(
{
"t": "bot_text_chunk",
"data": {"text": text},
}
)
# 发送音频数据给前端
await websocket.send_json(
{
"t": "response",
"data": data, # base64 编码的音频数据
}
)
elif result_type in ["complete", "end"]:
# 处理完成
logger.info(f"[Live Chat] Bot 回复完成: {bot_text}")
# 如果没有音频流,发送 bot 消息文本
if not audio_playing:
await websocket.send_json(
{
"t": "bot_msg",
"data": {
"text": bot_text,
"ts": int(time.time() * 1000),
},
}
)
# 发送结束标记
await websocket.send_json({"t": "end"})
# 发送总耗时
wav_to_tts_duration = time.time() - wav_assembly_finish_time
await websocket.send_json(
{
"t": "metrics",
"data": {"wav_to_tts_total_time": wav_to_tts_duration},
}
)
break
break
finally:
webchat_queue_mgr.remove_back_queue(message_id)
except Exception as e:
logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True)
+36
View File
@@ -31,6 +31,16 @@ class LogRoute(Route):
view_func=self.log_history,
methods=["GET"],
)
self.app.add_url_rule(
"/api/trace/settings",
view_func=self.get_trace_settings,
methods=["GET"],
)
self.app.add_url_rule(
"/api/trace/settings",
view_func=self.update_trace_settings,
methods=["POST"],
)
async def _replay_cached_logs(
self, last_event_id: str
@@ -106,3 +116,29 @@ class LogRoute(Route):
except Exception as e:
logger.error(f"获取日志历史失败: {e}")
return Response().error(f"获取日志历史失败: {e}").__dict__
async def get_trace_settings(self):
"""获取 Trace 设置"""
try:
trace_enable = self.config.get("trace_enable", True)
return Response().ok(data={"trace_enable": trace_enable}).__dict__
except Exception as e:
logger.error(f"获取 Trace 设置失败: {e}")
return Response().error(f"获取 Trace 设置失败: {e}").__dict__
async def update_trace_settings(self):
"""更新 Trace 设置"""
try:
data = await request.json
if data is None:
return Response().error("请求数据为空").__dict__
trace_enable = data.get("trace_enable")
if trace_enable is not None:
self.config["trace_enable"] = bool(trace_enable)
self.config.save_config()
return Response().ok(message="Trace 设置已更新").__dict__
except Exception as e:
logger.error(f"更新 Trace 设置失败: {e}")
return Response().error(f"更新 Trace 设置失败: {e}").__dict__
+11
View File
@@ -315,6 +315,17 @@ class PluginRoute(Route):
"display_name": plugin.display_name,
"logo": f"/api/file/{logo_url}" if logo_url else None,
}
# 检查是否为全空的幽灵插件
if not any(
[
plugin.name,
plugin.author,
plugin.desc,
plugin.version,
plugin.display_name,
]
):
continue
_plugin_resp.append(_t)
return (
Response()
+12 -37
View File
@@ -4,7 +4,6 @@ import traceback
from quart import request
from astrbot.core import DEMO_MODE, logger
from astrbot.core.computer.computer_client import get_booter
from astrbot.core.skills.skill_manager import SkillManager
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
@@ -25,14 +24,22 @@ class SkillsRoute(Route):
async def get_skills(self):
try:
cfg = self.core_lifecycle.astrbot_config.get("provider_settings", {}).get(
"skills", {}
provider_settings = self.core_lifecycle.astrbot_config.get(
"provider_settings", {}
)
runtime = cfg.get("runtime", "local")
runtime = provider_settings.get("computer_use_runtime", "local")
skills = SkillManager().list_skills(
active_only=False, runtime=runtime, show_sandbox_path=False
)
return Response().ok([skill.__dict__ for skill in skills]).__dict__
return (
Response()
.ok(
{
"skills": [skill.__dict__ for skill in skills],
}
)
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
@@ -60,41 +67,9 @@ class SkillsRoute(Route):
temp_path = os.path.join(temp_dir, filename)
await file.save(temp_path)
cfg = self.core_lifecycle.astrbot_config.get("provider_settings", {}).get(
"skills", {}
)
runtime = cfg.get("runtime", "local")
if runtime == "sandbox":
sandbox_enabled = (
self.core_lifecycle.astrbot_config.get("provider_settings", {})
.get("sandbox", {})
.get("enable", False)
)
if not sandbox_enabled:
return (
Response()
.error(
"Sandbox is not enabled. Please enable sandbox before using sandbox runtime."
)
.__dict__
)
skill_mgr = SkillManager()
skill_name = skill_mgr.install_skill_from_zip(temp_path, overwrite=True)
if runtime == "sandbox":
sb = await get_booter(self.core_lifecycle.star_context, "skills-upload")
remote_root = "/home/shared/skills"
remote_zip = f"{remote_root}/{skill_name}.zip"
await sb.shell.exec(f"mkdir -p {remote_root}")
upload_result = await sb.upload_file(temp_path, remote_zip)
if not upload_result.get("success", False):
return (
Response().error("Failed to upload skill to sandbox").__dict__
)
await sb.shell.exec(
f"unzip -o {remote_zip} -d {remote_root} && rm -f {remote_zip}"
)
return (
Response()
.ok({"name": skill_name}, "Skill uploaded successfully.")
+41 -14
View File
@@ -130,19 +130,25 @@ class ToolsRoute(Route):
server_data = await request.json
name = server_data.get("name", "")
old_name = server_data.get("oldName") or name
if not name:
return Response().error("服务器名称不能为空").__dict__
config = self.tool_mgr.load_mcp_config()
if name not in config["mcpServers"]:
return Response().error(f"服务器 {name} 不存在").__dict__
if old_name not in config["mcpServers"]:
return Response().error(f"服务器 {old_name} 不存在").__dict__
is_rename = name != old_name
if name in config["mcpServers"] and is_rename:
return Response().error(f"服务器 {name} 已存在").__dict__
# 获取活动状态
active = server_data.get(
"active",
config["mcpServers"][name].get("active", True),
config["mcpServers"][old_name].get("active", True),
)
# 创建新的配置对象
@@ -153,7 +159,13 @@ class ToolsRoute(Route):
# 复制所有配置字段
for key, value in server_data.items():
if key not in ["name", "active", "tools", "errlogs"]: # 排除特殊字段
if key not in [
"name",
"active",
"tools",
"errlogs",
"oldName",
]: # 排除特殊字段
if key == "mcpServers":
key_0 = list(server_data["mcpServers"].keys())[
0
@@ -165,29 +177,42 @@ class ToolsRoute(Route):
# 如果只更新活动状态,保留原始配置
if only_update_active:
for key, value in config["mcpServers"][name].items():
for key, value in config["mcpServers"][old_name].items():
if key != "active": # 除了active之外的所有字段都保留
server_config[key] = value
config["mcpServers"][name] = server_config
# config["mcpServers"][name] = server_config
if is_rename:
config["mcpServers"].pop(old_name)
config["mcpServers"][name] = server_config
else:
config["mcpServers"][name] = server_config
if self.tool_mgr.save_mcp_config(config):
# 处理MCP客户端状态变化
if active:
if name in self.tool_mgr.mcp_client_dict or not only_update_active:
if (
old_name in self.tool_mgr.mcp_client_dict
or not only_update_active
or is_rename
):
try:
await self.tool_mgr.disable_mcp_server(name, timeout=10)
await self.tool_mgr.disable_mcp_server(old_name, timeout=10)
except TimeoutError as e:
return (
Response()
.error(f"启用前停用 MCP 服务器时 {name} 超时: {e!s}")
.error(
f"启用前停用 MCP 服务器时 {old_name} 超时: {e!s}"
)
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return (
Response()
.error(f"启用前停用 MCP 服务器时 {name} 失败: {e!s}")
.error(
f"启用前停用 MCP 服务器时 {old_name} 失败: {e!s}"
)
.__dict__
)
try:
@@ -208,18 +233,20 @@ class ToolsRoute(Route):
.__dict__
)
# 如果要停用服务器
elif name in self.tool_mgr.mcp_client_dict:
elif old_name in self.tool_mgr.mcp_client_dict:
try:
await self.tool_mgr.disable_mcp_server(name, timeout=10)
await self.tool_mgr.disable_mcp_server(old_name, timeout=10)
except TimeoutError:
return (
Response().error(f"停用 MCP 服务器 {name} 超时。").__dict__
Response()
.error(f"停用 MCP 服务器 {old_name} 超时。")
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return (
Response()
.error(f"停用 MCP 服务器 {name} 失败: {e!s}")
.error(f"停用 MCP 服务器 {old_name} 失败: {e!s}")
.__dict__
)
+7 -3
View File
@@ -2,14 +2,13 @@ import asyncio
import logging
import os
import socket
from typing import cast
from typing import Protocol, cast
import jwt
import psutil
from flask.json.provider import DefaultJSONProvider
from hypercorn.asyncio import serve
from hypercorn.config import Config as HyperConfig
from psutil._common import addr as psutil_addr
from quart import Quart, g, jsonify, request
from quart.logging import default_handler
@@ -29,6 +28,11 @@ from .routes.session_management import SessionManagementRoute
from .routes.subagent import SubAgentRoute
from .routes.t2i import T2iRoute
class _AddrWithPort(Protocol):
port: int
APP: Quart
@@ -168,7 +172,7 @@ class AstrBotDashboard:
"""获取占用端口的进程详细信息"""
try:
for conn in psutil.net_connections(kind="inet"):
if cast(psutil_addr, conn.laddr).port == port:
if cast(_AddrWithPort, conn.laddr).port == port:
try:
process = psutil.Process(conn.pid)
# 获取详细信息
+72
View File
@@ -0,0 +1,72 @@
## What's Changed - BIG AND BEAUTIFUL VERSION
> 如果在之前版本使用了 Skill,这次更新之后**需要重新配置** Skill Runtime 相关选项。
### 新增
- 🔥 新增未来任务系统(Future Tasks)。给 AstrBot 布置的未来任务,让 AstrBot 能够在某一时刻自动唤醒,帮你完成任务。详见 [主动任务](https://docs.astrbot.app/use/proactive-agent.html) 。(实验性) ([#4697](https://github.com/AstrBotDevs/AstrBot/issues/4831))
- 🔥 新增子代理(SubAgent)编排器。(实验性)([#4697](https://github.com/AstrBotDevs/AstrBot/issues/4831))
- 🔥 AstrBot 目前可以直接通过调用 tool 将图片 / 文件推送给用户,大大提高交互效果。
- 新增 Computer Use 运行时配置,以融合 Skill 和 Sandbox 配置 ([#4831](https://github.com/AstrBotDevs/AstrBot/issues/4831))
- 新增主题自定义功能,可设置主色与辅色
- 支持在配置页下人格对话框的编辑人格 ([#4826](https://github.com/AstrBotDevs/AstrBot/issues/4826))
- 支持开关 “追踪” 功能;支持在系统配置中设置是否将日志写入 log 文件 ([#4822](https://github.com/AstrBotDevs/AstrBot/issues/4822))
### 修复
- ‼️ 修复 ChatUI 图片、思考等显示异常问题。
- ‼️ 修复 Skill 上传到 Sandbox 后未自动解压导致 Agent 无法读取的问题。
- ‼️ 修复配置特定插件集时 MCP 工具被过滤的问题 ([#4825](https://github.com/AstrBotDevs/AstrBot/issues/4825))
- ‼️ 移除 ChatUI 自带的让 LLM 最后提出问题的 prompt ([#4824](https://github.com/AstrBotDevs/AstrBot/issues/4824))
- ‼️ 修复 WebUI 在上传 Skill 失败后仍显示成功消息的 bug ([#4768](https://github.com/AstrBotDevs/AstrBot/issues/4768))
- 修复 MCP 服务器无法重命名的问题 ([#4766](https://github.com/AstrBotDevs/AstrBot/issues/4766))
- 修复插件的 tool 无法在 WebUI 管理行为中看到来源的问题 ([#4776](https://github.com/AstrBotDevs/AstrBot/issues/4776))
- ‼️ 修复 skill-like 的 tool 模式下,调用 tool 失败的问题 ([#4775](https://github.com/AstrBotDevs/AstrBot/issues/4775))
### 优化
- WebUI 整体 UI 效果优化
- 部分 Dialog 标题样式统一
## What's Changed (EN)
### New Features
- Introduce CronJob system with one-time tasks and enhanced dashboard management
- Add theme customization with primary & secondary color options
- Add computer-use runtime config for skills sandbox execution ([#4831](https://github.com/AstrBotDevs/AstrBot/issues/4831))
- Add edit button to persona selector dialog ([#4826](https://github.com/AstrBotDevs/AstrBot/issues/4826))
- Add trace logging toggle and configuration UI ([#4822](https://github.com/AstrBotDevs/AstrBot/issues/4822))
- Add proactive-messaging capability with cron-tool trigger
- Implement SubAgent orchestrator with configurable tool-management policies
- Support resolving sandbox file paths and auto-download when necessary
- Add embedded image & audio styles in MessagePartsRenderer
- Introduce i18n foundation
- Persist agent-interaction history
- Add user notifications for file-download success/removal
### Bug Fixes
- Improve ghost-plugin detection accuracy
- Add error handling to prevent ghost-plugin crashes
- Prevent skills bundle from overwriting existing files
- Fix skills bundle unzip failure inside sandbox
- Fix MCP tools being filtered when specific plugin set configured ([#4825](https://github.com/AstrBotDevs/AstrBot/issues/4825))
- Merge ChatUI persona pop-up into default persona ([#4824](https://github.com/AstrBotDevs/AstrBot/issues/4824))
- Fix reasoning block style
- Add missing comma in truncate_and_compress hint
- Fix frontend still showing success message ([#4768](https://github.com/AstrBotDevs/AstrBot/issues/4768))
- Fix unable to rename MCP server ([#4766](https://github.com/AstrBotDevs/AstrBot/issues/4766))
- Remove leftover sandbox runtime handling in skill upload ([#4798](https://github.com/AstrBotDevs/AstrBot/issues/4798))
- Fix handler module path construction ([#4776](https://github.com/AstrBotDevs/AstrBot/issues/4776))
- Fix skill-like tool invocation error ([#4775](https://github.com/AstrBotDevs/AstrBot/issues/4775))
### Improvements
- Runtime hints & refined UI in skills management
- Performance and UX improvements on cron-job page
- General WebUI performance boost
- Group tools by plugin in dropdown
- Consistent dialog titles with padding and text styles
- Code formatting unified (ruff format)
- Bump version to 4.13.2
### Others
- Remove obsolete reminder code
- Extract main-agent module for better architecture
- Merge AstrBot_skill branch changes
+7
View File
@@ -0,0 +1,7 @@
## What's Changed - BIG AND BEAUTIFUL VERSION
hotfix of v4.14.0
fixes:
- 由 `event.request_llm()` 过时导致的群聊上下文感知-主动回复功能可能不可用的问题
+23
View File
@@ -0,0 +1,23 @@
## What's Changed
### 新增
- 控制台页面新增调试提示和本地化文件 ([#4852](https://github.com/AstrBotDevs/AstrBot/pull/4852))
### 修复
- 修复插件热重载时平台适配器未清理导致注册冲突的问题 ([#4859](https://github.com/AstrBotDevs/AstrBot/pull/4859))
### 其他
- 更新 ruff 版本至 0.15.0
- 新增 robots.txt ([#4847](https://github.com/AstrBotDevs/AstrBot/pull/4847))
## What's Changed (EN)
### New Features
- Add debug hint to console page and localization files ([#4852](https://github.com/AstrBotDevs/AstrBot/pull/4852))
### Bug Fixes
- Fix platform adapter not being cleaned up during plugin hot reload, causing registration conflicts ([#4859](https://github.com/AstrBotDevs/AstrBot/pull/4859))
### Others
- Update ruff version to 0.15.0
- Add robots.txt ([#4847](https://github.com/AstrBotDevs/AstrBot/pull/4847))
+4
View File
@@ -0,0 +1,4 @@
## What's Changed
### 修复
- 修复 `on_llm_request` 钩子可能无法应用效果的问题
+4
View File
@@ -0,0 +1,4 @@
## What's Changed
### 修复
- 修复 token 统计错误的问题,修复在多轮 tool call 情况下或者其他极端情况下可能造成 tool 无限调用的问题。
+11
View File
@@ -0,0 +1,11 @@
## What's Changed
### Fix
- fix: `fix: messages[x] assistant content must contain at least one part` after tool calling ([#4928](https://github.com/AstrBotDevs/AstrBot/issues/4928)) after tool calls.
- fix: TypeError when MCP schema type is a list ([#4867](https://github.com/AstrBotDevs/AstrBot/issues/4867))
- fix: Fixed an issue that caused scheduled task execution failures with specific providers 修复特定提供商导致的定时任务执行失败的问题 ([#4872](https://github.com/AstrBotDevs/AstrBot/issues/4872))
### Feature
- feat: add bocha web search tool ([#4902](https://github.com/AstrBotDevs/AstrBot/issues/4902))
- feat: systemd support ([#4880](https://github.com/AstrBotDevs/AstrBot/issues/4880))
+10
View File
@@ -0,0 +1,10 @@
## What's Changed
### 修复
- 修复一些原因导致 Tavily WebSearch、Bocha WebSearch 无法使用的问题
### xinzeng
- 飞书支持 Bot 发送文件、图片和视频消息类型。
### 优化
- 优化 WebChat 和 企业微信 AI 会话队列生命周期管理,减少内存泄漏,提高性能。
+1
View File
@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="keywords" content="AstrBot Soulter" />
<meta name="description" content="AstrBot Dashboard" />
<meta name="robots" content="noindex, nofollow" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Outfit&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap"
+1 -1
View File
@@ -30,7 +30,7 @@
"markdown-it": "^14.1.0",
"markstream-vue": "^0.0.6",
"mermaid": "^11.12.2",
"monaco-editor": "^0.55.1",
"monaco-editor": "^0.52.2",
"pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
"remixicon": "3.5.0",
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+1 -31
View File
@@ -92,6 +92,7 @@
<!-- Reasoning Block (Collapsible) - 放在最前面 -->
<ReasoningBlock v-if="msg.content.reasoning && msg.content.reasoning.trim()"
:reasoning="msg.content.reasoning" :is-dark="isDark"
class="mt-2"
:initial-expanded="isReasoningExpanded(index)" />
<MessagePartsRenderer :parts="msg.content.message" :is-dark="isDark"
@@ -1203,37 +1204,6 @@ export default {
border-radius: 18px;
}
.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 {
@@ -331,4 +331,86 @@ const getRenderParts = (messageParts) => {
.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>
@@ -1,18 +1,15 @@
<template>
<div class="mb-3 mt-1.5 border border-gray-200 dark:border-gray-700 rounded-2xl overflow-hidden w-fit"
:class="{ 'dark:bg-purple-900/8': isDark, 'bg-purple-50/50': !isDark }">
<div class="inline-flex items-center px-2 py-2 cursor-pointer select-none rounded-2xl transition-colors hover:bg-purple-50/80 dark:hover:bg-purple-900/15"
@click="toggleExpanded">
<v-icon size="small" class="mr-1.5 text-purple-600 dark:text-purple-400 transition-transform"
:class="{ 'rotate-90': isExpanded }">
<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="text-sm font-medium text-purple-600 dark:text-purple-400 tracking-wide">
<span class="reasoning-title">
{{ tm('reasoning.thinking') }}
</span>
</div>
<div v-if="isExpanded" class="px-3 border-t border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 animate-fade-in italic">
<MarkdownRender :content="reasoning" class="reasoning-text markdown-content text-sm leading-relaxed"
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
<MarkdownRender :content="reasoning" class="reasoning-text markdown-content"
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
</div>
</div>
@@ -47,6 +44,63 @@ const toggleExpanded = () => {
</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-header {
display: inline-flex;
align-items: center;
padding: 8px 8px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
border-radius: 20px;
}
.reasoning-header:hover {
background-color: rgba(103, 58, 183, 0.08);
}
.reasoning-header.is-dark:hover {
background-color: rgba(103, 58, 183, 0.15);
}
.reasoning-icon {
margin-right: 6px;
color: var(--v-theme-secondary);
transition: transform 0.2s ease;
}
.reasoning-label {
font-size: 13px;
font-weight: 500;
color: var(--v-theme-secondary);
letter-spacing: 0.3px;
}
.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;
}
.reasoning-text {
font-size: 14px;
line-height: 1.6;
color: var(--v-theme-secondaryText);
}
.animate-fade-in {
animation: fadeIn 0.2s ease-in-out;
}
@@ -65,9 +119,4 @@ const toggleExpanded = () => {
transform: rotate(90deg);
}
.reasoning-text {
font-size: 14px;
line-height: 1.6;
color: var(--v-theme-secondaryText);
}
</style>
@@ -81,10 +81,10 @@
</v-container>
<!-- 添加/编辑 MCP 服务器对话框 -->
<v-dialog v-model="showMcpServerDialog" max-width="750px" persistent>
<v-dialog v-model="showMcpServerDialog" max-width="750px">
<v-card>
<v-card-title class="bg-primary text-white py-3">
<v-icon color="white" class="me-2">{{ isEditMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
<v-card-title class="pa-4 pl-6">
<v-icon class="me-2">{{ isEditMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
<span>{{ isEditMode ? tm('dialogs.addServer.editTitle') : tm('dialogs.addServer.title') }}</span>
</v-card-title>
@@ -251,6 +251,7 @@ export default {
active: true,
tools: []
},
originalServerName: '',
save_message_snack: false,
save_message: '',
save_message_success: 'success'
@@ -359,6 +360,9 @@ export default {
active: this.currentServer.active,
...configObj
};
if (this.isEditMode && this.originalServerName) {
serverData.oldName = this.originalServerName;
}
const endpoint = this.isEditMode ? '/api/tools/mcp/update' : '/api/tools/mcp/add';
axios.post(endpoint, serverData)
.then(response => {
@@ -402,6 +406,7 @@ export default {
active: server.active,
tools: server.tools || []
};
this.originalServerName = server.name;
this.serverConfigJson = JSON.stringify(configCopy, null, 2);
this.isEditMode = true;
this.showMcpServerDialog = true;
@@ -461,6 +466,7 @@ export default {
this.serverConfigJson = '';
this.jsonError = null;
this.isEditMode = false;
this.originalServerName = '';
},
showSuccess(message) {
this.save_message = message;
@@ -3,8 +3,7 @@
<v-container fluid class="pa-0" elevation="0">
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<div>
<v-btn color="success" prepend-icon="mdi-upload" class="me-2" variant="tonal"
@click="uploadDialog = true">
<v-btn color="success" prepend-icon="mdi-upload" class="me-2" variant="tonal" @click="uploadDialog = true">
{{ tm('skills.upload') }}
</v-btn>
<v-btn color="primary" prepend-icon="mdi-refresh" variant="tonal" @click="fetchSkills">
@@ -13,6 +12,10 @@
</div>
</v-row>
<div class="px-2 pb-2">
<small style="color: grey;">{{ tm('skills.runtimeHint') }}</small>
</div>
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<div v-else-if="skills.length === 0" class="text-center pa-8">
@@ -40,13 +43,13 @@
</v-row>
</v-container>
<v-dialog v-model="uploadDialog" max-width="520px" persistent>
<v-dialog v-model="uploadDialog" max-width="520px">
<v-card>
<v-card-title>{{ tm('skills.uploadDialogTitle') }}</v-card-title>
<v-card-title class="text-h3 pa-4 pb-0 pl-6">{{ tm('skills.uploadDialogTitle') }}</v-card-title>
<v-card-text>
<small class="text-grey">{{ tm('skills.uploadHint') }}</small>
<v-file-input v-model="uploadFile" accept=".zip" :label="tm('skills.selectFile')" prepend-icon="mdi-file-zip"
variant="outlined" class="mt-4" :multiple="false" />
<v-file-input v-model="uploadFile" accept=".zip" :label="tm('skills.selectFile')"
prepend-icon="mdi-folder-zip-outline" variant="outlined" class="mt-4" :multiple="false" />
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" @click="uploadDialog = false">{{ tm('skills.cancel') }}</v-btn>
@@ -110,7 +113,12 @@ export default {
loading.value = true;
try {
const res = await axios.get("/api/skills");
skills.value = res.data.data || [];
const payload = res.data?.data || [];
if (Array.isArray(payload)) {
skills.value = payload;
} else {
skills.value = payload.skills || [];
}
} catch (err) {
showMessage(tm("skills.loadFailed"), "error");
} finally {
@@ -119,8 +119,17 @@
</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedItemId === getItemId(item)"
color="primary" size="22">mdi-check-circle</v-icon>
<div class="d-flex align-center ga-1">
<v-btn v-if="showEditButton && !isDefaultItem(item)"
icon="mdi-pencil"
size="small"
variant="text"
@click.stop="handleEditItem(item)"
:title="labels.editButton || 'Edit'"
/>
<v-icon v-if="selectedItemId === getItemId(item)"
color="primary" size="22">mdi-check-circle</v-icon>
</div>
</template>
</v-list-item>
</template>
@@ -197,6 +206,11 @@ export default defineComponent({
type: Boolean,
default: false
},
//
showEditButton: {
type: Boolean,
default: false
},
// ""
defaultItem: {
type: Object as PropType<SelectableItem | null>,
@@ -221,7 +235,7 @@ export default defineComponent({
default: null
}
},
emits: ['update:modelValue', 'navigate', 'create'],
emits: ['update:modelValue', 'navigate', 'create', 'edit'],
data() {
return {
dialog: false,
@@ -370,6 +384,17 @@ export default defineComponent({
cancelSelection() {
this.selectedItemId = this.modelValue || '';
this.dialog = false;
},
isDefaultItem(item: SelectableItem): boolean {
if (this.defaultItem === null) {
return false;
}
return this.getItemId(item) === this.getItemId(this.defaultItem);
},
handleEditItem(item: SelectableItem) {
this.$emit('edit', item);
}
}
});
+1
View File
@@ -241,6 +241,7 @@ export interface FolderItemSelectorLabels {
// 按钮
createButton?: string;
editButton?: string;
confirmButton?: string;
cancelButton?: string;
@@ -1,4 +1,5 @@
<script setup>
import MarkdownIt from 'markdown-it'
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { ref, computed } from 'vue'
import ConfigItemRenderer from './ConfigItemRenderer.vue'
@@ -24,12 +25,23 @@ const props = defineProps({
const { t } = useI18n()
const { tm, getRaw } = useModuleI18n('features/config-metadata')
const hintMarkdown = new MarkdownIt({
linkify: true,
breaks: true
})
// -
const translateIfKey = (value) => {
if (!value || typeof value !== 'string') return value
return tm(value)
}
const renderHint = (value) => {
const text = translateIfKey(value)
if (!text) return ''
return hintMarkdown.renderInline(text)
}
// labels - labels
const getTranslatedLabels = (itemMeta) => {
if (!itemMeta?.labels) return null
@@ -185,7 +197,7 @@ function getSpecialSubtype(value) {
</v-list-item-title>
<v-list-item-subtitle class="config-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint"></span>
{{ translateIfKey(metadata[metadataKey]?.hint) }}
<span v-html="renderHint(metadata[metadataKey]?.hint)"></span>
</v-list-item-subtitle>
</v-card-text>
@@ -205,7 +217,7 @@ function getSpecialSubtype(value) {
<v-list-item-subtitle class="property-hint">
<span v-if="itemMeta?.obvious_hint && itemMeta?.hint" class="important-hint"></span>
{{ translateIfKey(itemMeta?.hint) }}
<span v-html="renderHint(itemMeta?.hint)"></span>
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -293,6 +305,12 @@ function getSpecialSubtype(value) {
margin-top: 2px;
}
.config-hint :deep(a),
.property-hint :deep(a) {
color: var(--v-theme-primary);
text-decoration: underline;
}
.metadata-key,
.property-key {
font-size: 0.85em;
@@ -530,8 +530,13 @@ export default {
try {
const response = await axios.get('/api/skills');
if (response.data.status === 'ok') {
const skills = response.data.data || [];
this.availableSkills = skills.filter(skill => skill.active !== false);
const payload = response.data.data || [];
if (Array.isArray(payload)) {
this.availableSkills = payload.filter(skill => skill.active !== false);
} else {
const skills = payload.skills || [];
this.availableSkills = skills.filter(skill => skill.active !== false);
}
} else {
this.$emit('error', response.data.message || 'Failed to load skills');
}
@@ -8,6 +8,7 @@
:items-loading="itemsLoading"
:labels="labels"
:show-create-button="true"
:show-edit-button="true"
:default-item="defaultPersona"
item-id-field="persona_id"
item-name-field="persona_id"
@@ -15,15 +16,16 @@
:display-value-formatter="formatDisplayValue"
@navigate="handleNavigate"
@create="openCreatePersona"
@edit="openEditPersona"
/>
<!-- 创建人格对话框 -->
<!-- 创建/编辑人格对话框 -->
<PersonaForm
v-model="showCreateDialog"
:editing-persona="undefined"
v-model="showPersonaDialog"
:editing-persona="editingPersona ?? undefined"
:current-folder-id="currentFolderId ?? undefined"
:current-folder-name="currentFolderName ?? undefined"
@saved="handlePersonaCreated"
@saved="handlePersonaSaved"
@error="handleError" />
</template>
@@ -62,7 +64,8 @@ const folderTree = ref<FolderTreeNode[]>([])
const currentPersonas = ref<Persona[]>([])
const treeLoading = ref(false)
const itemsLoading = ref(false)
const showCreateDialog = ref(false)
const showPersonaDialog = ref(false)
const editingPersona = ref<Persona | null>(null)
const currentFolderId = ref<string | null>(null)
//
@@ -104,6 +107,7 @@ const labels = computed(() => ({
defaultItem: tm('personaSelector.defaultPersona'),
noDescription: tm('personaSelector.noDescription'),
createButton: tm('personaSelector.createPersona'),
editButton: tm('personaSelector.editPersona') || 'Edit',
confirmButton: t('core.common.confirm'),
cancelButton: t('core.common.cancel'),
rootFolder: tm('personaSelector.rootFolder') || '全部人格',
@@ -171,13 +175,21 @@ async function handleNavigate(folderId: string | null) {
//
function openCreatePersona() {
showCreateDialog.value = true
editingPersona.value = null
showPersonaDialog.value = true
}
//
async function handlePersonaCreated(message: string) {
console.log('人格创建成功:', message)
showCreateDialog.value = false
//
function openEditPersona(persona: Persona) {
editingPersona.value = persona
showPersonaDialog.value = true
}
//
async function handlePersonaSaved(message: string) {
console.log('人格保存成功:', message)
showPersonaDialog.value = false
editingPersona.value = null
//
await loadPersonasInFolder(currentFolderId.value)
}
@@ -10,6 +10,13 @@
"chat": "Chat",
"cron": "Future Tasks",
"extension": "Extensions",
"extensionTabs": {
"installed": "AstrBot Plugins",
"market": "Plugin Market",
"mcp": "MCP Servers",
"skills": "Skills",
"components": "Handlers"
},
"conversation": "Conversations",
"sessionManagement": "Custom Rules",
"console": "Console",
@@ -108,9 +108,13 @@
"description": "Tavily API Key",
"hint": "Multiple keys can be added for rotation."
},
"websearch_bocha_key": {
"description": "BoCha API Key",
"hint": "Multiple keys can be added for rotation."
},
"websearch_baidu_app_builder_key": {
"description": "Baidu Qianfan Smart Cloud APP Builder API Key",
"hint": "Reference: https://console.bce.baidu.com/iam/#/iam/apikey/list"
"hint": "Reference: [https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
},
"web_search_link": {
"description": "Display Source Citations"
@@ -133,15 +137,15 @@
}
}
},
"sandbox": {
"description": "Agent Sandbox Env(Beta)",
"hint": "https://docs.astrbot.app/en/use/astrbot-agent-sandbox.html",
"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)",
"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."
},
"sandbox": {
"enable": {
"description": "Enable Sandbox Env",
"hint": "When enabled, Agent can use tools and resources in the sandbox environment, such as Python tool, Shell, etc."
},
"booter": {
"description": "Sandbox Environment Driver"
},
@@ -164,18 +168,20 @@
}
}
},
"skills": {
"description": "Skills",
"proactive_capability": {
"description": "Proactive Agent",
"hint": "AstrBot will wake up, run your tasks, and deliver the results to you. See [Proactive Agent](https://docs.astrbot.app/en/use/proactive-agent.html)",
"provider_settings": {
"skills": {
"runtime": {
"description": "Skill Runtime",
"hint": "Select the runtime for Skills. Sandbox runtime requires sandbox to be enabled first. In local mode, the Agent CAN FULLY ACCESS the runtime environment through Shell and Python tools, but non-admin users will be automatically prohibited from using it to ensure security."
"proactive_capability": {
"add_cron_tools": {
"description": "Enable",
"hint": "When enabled, related tools will be passed to the Agent to implement proactive Agent capabilities. You can tell AstrBot what to do at a future time, and it will be triggered on schedule to execute the task, and report the result back to you."
}
}
}
},
"truncate_and_compress": {
"hint": "[Context Management](https://docs.astrbot.app/en/use/context-compress.html)",
"description": "Context Management Strategy",
"provider_settings": {
"max_context_length": {
@@ -426,7 +432,7 @@
},
"emojis": {
"description": "Emoji List (Lark Emoji Enum Names)",
"hint": "Emoji enum names reference: https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce"
"hint": "Emoji enum names reference: [https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce](https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce)"
}
}
},
@@ -437,7 +443,7 @@
},
"emojis": {
"description": "Emoji List (Unicode)",
"hint": "Telegram only supports a fixed reaction set, reference: https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9"
"hint": "Telegram only supports a fixed reaction set, reference: [https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9](https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9)"
}
}
}
@@ -594,15 +600,15 @@
},
"pypi_index_url": {
"description": "PyPI Repository URL",
"hint": "PyPI repository URL for installing Python dependencies. Defaults to https://mirrors.aliyun.com/pypi/simple/"
"hint": "PyPI repository URL for installing Python dependencies. Defaults to [https://mirrors.aliyun.com/pypi/simple/](https://mirrors.aliyun.com/pypi/simple/)"
},
"callback_api_base": {
"description": "Externally Accessible Callback API Address",
"hint": "External services may access AstrBot's backend through callback links generated by AstrBot (such as file download links). Since AstrBot cannot automatically determine the externally accessible host address in the deployment environment, this configuration item is needed to explicitly specify how external services should access AstrBot's address. Examples: http://localhost:6185, https://example.com, etc."
"hint": "External services may access AstrBot's backend through callback links generated by AstrBot (such as file download links). Since AstrBot cannot automatically determine the externally accessible host address in the deployment environment, this configuration item is needed to explicitly specify how external services should access AstrBot's address. Examples: [http://localhost:6185](http://localhost:6185), [https://example.com](https://example.com), etc."
},
"timezone": {
"description": "Timezone",
"hint": "Timezone setting. Please enter an IANA timezone name, such as Asia/Shanghai. Uses system default timezone when empty. For all timezones, see: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab"
"hint": "Timezone setting. Please enter an IANA timezone name, such as Asia/Shanghai. Uses system default timezone when empty. For all timezones, see: [https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab](https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab)"
},
"http_proxy": {
"description": "HTTP Proxy",
@@ -11,5 +11,8 @@
"mirrorLabel": "Force PyPI repository URL (optional)",
"mirrorHint": "Force PyPI repository URL > Config item `PyPI Repository Address`",
"installButton": "Install"
},
"debugHint": {
"text": "Debug logs can be enabled in \"Configuration File → System → Console Log Level\""
}
}
}
@@ -1,7 +1,7 @@
{
"page": {
"title": "Future Task Management",
"beta": "Beta",
"beta": "Experimental",
"subtitle": "See scheduled tasks for AstrBot. AstrBot will wake up, run them, and deliver the results.",
"proactive": {
"supported": "Proactive delivery is available on: {platforms}",
@@ -22,6 +22,7 @@
"name": "Name",
"type": "Type",
"cron": "Cron",
"session": "Session ID",
"nextRun": "Next Run",
"lastRun": "Last Run",
"note": "Note",
@@ -39,6 +40,7 @@
},
"form": {
"title": "New Task",
"chatHint": "You can ask AstrBot in chat to create future tasks instead of adding them here.",
"runOnce": "One-off task",
"name": "Task name",
"note": "Task description",
@@ -2,11 +2,11 @@
"title": "Extension Management",
"subtitle": "Manage and configure system extensions",
"tabs": {
"installedPlugins": "Installed Plugins",
"installedMcpServers": "Installed MCP Servers",
"installedPlugins": "AstrBot Plugins",
"installedMcpServers": "MCP",
"skills": "Skills",
"handlersOperation": "Manage Handlers",
"market": "Extension Market"
"market": "AstrBot Plugin Market"
},
"search": {
"placeholder": "Search extensions...",
@@ -210,7 +210,9 @@
"deleteTitle": "Delete confirmation",
"deleteMessage": "Are you sure you want to delete this Skill?",
"deleteSuccess": "Deleted successfully",
"deleteFailed": "Delete failed"
"deleteFailed": "Delete failed",
"runtimeNoneWarning": "Computer Use runtime is set to None; Skills may not run correctly because no runtime is enabled.",
"runtimeHint": "Set the Computer Use runtime to Local or Sandbox in settings so AstrBot can use your Skills."
},
"card": {
"actions": {
@@ -49,6 +49,7 @@
"loadingSkills": "Loading skills...",
"allSkillsAvailable": "Use all available Skills",
"noSkillsSelected": "No skills selected",
"skillsRuntimeNoneWarning": "Computer Use runtime is set to None; Skills may not run correctly because no runtime is enabled.",
"createInFolder": "Will be created in \"{folder}\"",
"rootFolder": "All Personas"
},
@@ -16,6 +16,16 @@
"custom": "Custom"
}
},
"theme": {
"title": "Theme",
"subtitle": "Customize theme primary and secondary colors. Changes apply immediately and are stored locally in your browser.",
"customize": {
"title": "Theme Colors",
"primary": "Primary Color",
"secondary": "Secondary Color",
"reset": "Reset to Default"
}
},
"system": {
"title": "System",
"restart": {
@@ -119,4 +129,4 @@
"ftpHint": "For large backup files, you can also upload directly to the data/backups directory via FTP/SFTP"
}
}
}
}
@@ -1,7 +1,7 @@
{
"page": {
"title": "SubAgent Orchestration",
"beta": "Beta",
"beta": "Experimental",
"subtitle": "The main LLM only chats and delegates; tools live on individual SubAgents."
},
"actions": {
@@ -3,5 +3,8 @@
"autoScroll": {
"enabled": "Auto-scroll: On",
"disabled": "Auto-scroll: Off"
}
},
"hint": "Currently only recording partial model call paths from AstrBot main Agent. More coverage will be added.",
"recording": "Recording",
"paused": "Paused"
}
@@ -7,6 +7,13 @@
"subagent": "SubAgent 编排",
"toolUse": "MCP",
"extension": "插件",
"extensionTabs": {
"installed": "AstrBot 插件",
"market": "插件市场",
"mcp": "MCP",
"skills": "Skills",
"components": "管理行为"
},
"config": "配置文件",
"chat": "聊天",
"cron": "未来任务",
@@ -70,6 +70,7 @@
},
"persona": {
"description": "人格",
"hint": "赋予 AstrBot 人格。",
"provider_settings": {
"default_personality": {
"description": "默认采用的人格"
@@ -78,6 +79,7 @@
},
"knowledgebase": {
"description": "知识库",
"hint": "AstrBot 的 “外置大脑”。",
"kb_names": {
"description": "知识库列表",
"hint": "支持多选"
@@ -97,6 +99,7 @@
},
"websearch": {
"description": "网页搜索",
"hint": "让 AstrBot 能够访问互联网,获悉时讯。",
"provider_settings": {
"web_search": {
"description": "启用网页搜索"
@@ -108,9 +111,13 @@
"description": "Tavily API Key",
"hint": "可添加多个 Key 进行轮询。"
},
"websearch_bocha_key": {
"description": "BoCha API Key",
"hint": "可添加多个 Key 进行轮询。"
},
"websearch_baidu_app_builder_key": {
"description": "百度千帆智能云 APP Builder API Key",
"hint": "参考:https://console.bce.baidu.com/iam/#/iam/apikey/list"
"hint": "参考:[https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
},
"web_search_link": {
"description": "显示来源引用"
@@ -133,15 +140,15 @@
}
}
},
"sandbox": {
"description": "Agent 沙箱环境(Beta)",
"hint": "https://docs.astrbot.app/use/astrbot-agent-sandbox.html",
"agent_computer_use": {
"description": "使用电脑能力",
"hint": "让 AstrBot 访问和使用你的电脑或者隔离的沙盒环境,以执行更复杂的任务。详见: [沙盒模式](https://docs.astrbot.app/use/astrbot-agent-sandbox.html), [Skills](https://docs.astrbot.app/use/skills.html)。",
"provider_settings": {
"computer_use_runtime": {
"description": "运行环境",
"hint": "sandbox 代表在沙箱环境中运行, local 代表在本地环境中运行, none 代表不启用。如果上传了 skills,选择 none 会导致其无法被 Agent 正常使用。"
},
"sandbox": {
"enable": {
"description": "启用沙箱环境",
"hint": "启用后,Agent 可以使用沙箱环境中的工具和资源,如 Python 代码执行、Shell 等。"
},
"booter": {
"description": "沙箱环境驱动器"
},
@@ -164,18 +171,20 @@
}
}
},
"skills": {
"description": "Skills",
"proactive_capability": {
"description": "主动型能力",
"hint": "让 AstrBot 能够在某一时刻自动唤醒,帮你完成任务。详见: [主动型 Agent](https://docs.astrbot.app/use/proactive-agent.html)。",
"provider_settings": {
"skills": {
"runtime": {
"description": "Skill Runtime",
"hint": "选择 Skills 运行环境。使用 sandbox 前需启用沙箱;local 模式下 Agent 可通过 Shell 和 Python 功能完全访问运行环境,非管理员将被自动禁止使用以保证安全。"
"proactive_capability": {
"add_cron_tools": {
"description": "启用",
"hint": "启用后,将会传递给 Agent 相关工具来实现主动型 Agent。你可以告诉 AstrBot 未来某个时间要做的事情,它将被定时触发然后执行任务,然后将结果发送给你。"
}
}
}
},
"truncate_and_compress": {
"hint": "AstrBot 如何管理工作记忆。详见: [上下文管理策略](https://docs.astrbot.app/use/context-compress.html)。",
"description": "上下文管理策略",
"provider_settings": {
"max_context_length": {
@@ -424,7 +433,7 @@
},
"emojis": {
"description": "表情列表(飞书表情枚举名)",
"hint": "表情枚举名参考:https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce"
"hint": "表情枚举名参考:[https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce](https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce)"
}
}
},
@@ -435,7 +444,7 @@
},
"emojis": {
"description": "表情列表(Unicode)",
"hint": "Telegram 仅支持固定反应集合,参考:https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9"
"hint": "Telegram 仅支持固定反应集合,参考:[https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9](https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9)"
}
}
}
@@ -592,15 +601,15 @@
},
"pypi_index_url": {
"description": "PyPI 软件仓库地址",
"hint": "安装 Python 依赖时请求的 PyPI 软件仓库地址。默认为 https://mirrors.aliyun.com/pypi/simple/"
"hint": "安装 Python 依赖时请求的 PyPI 软件仓库地址。默认为 [https://mirrors.aliyun.com/pypi/simple/](https://mirrors.aliyun.com/pypi/simple/)"
},
"callback_api_base": {
"description": "对外可达的回调接口地址",
"hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址(host),因此需要通过此配置项显式指定外部服务如何访问 AstrBot 的地址。如 http://localhost:6185,https://example.com 等。"
"hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址(host),因此需要通过此配置项显式指定外部服务如何访问 AstrBot 的地址。如 [http://localhost:6185](http://localhost:6185),[https://example.com](https://example.com) 等。"
},
"timezone": {
"description": "时区",
"hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab"
"hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: [https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab](https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab)"
},
"http_proxy": {
"description": "HTTP 代理",
@@ -11,5 +11,8 @@
"mirrorLabel": "强制 PyPI 软件仓库链接(可选)",
"mirrorHint": "强制 PyPI 软件仓库链接 > 配置项 `PyPI 软件仓库地址`",
"installButton": "安装"
},
"debugHint": {
"text": "Debug 日志需要在「配置文件 → 系统 → 控制台日志级别」中开启"
}
}
}
@@ -1,10 +1,10 @@
{
"page": {
"title": "未来任务管理",
"beta": "Beta",
"subtitle": "查看给 AstrBot 布置的未来任务。AstrBot 将会被自动唤醒、执行任务,然后将结果告知任务布置方。",
"beta": "实验性",
"subtitle": "查看给 AstrBot 布置的未来任务。AstrBot 将会被自动唤醒、执行任务,然后将结果告知任务布置方。需要先在配置文件中启用“主动型能力”。",
"proactive": {
"supported": "主动发送结果仅支持以下平台:{platforms}",
"supported": "主动发送结果仅支持以下您已配置的平台:{platforms}",
"unsupported": "暂无支持主动消息的平台,请在平台设置中开启。"
}
},
@@ -22,6 +22,7 @@
"name": "名称",
"type": "类型",
"cron": "Cron",
"session": "会话 ID",
"nextRun": "下一次执行",
"lastRun": "最近执行",
"note": "说明",
@@ -39,6 +40,7 @@
},
"form": {
"title": "新建任务",
"chatHint": "你可以直接通过聊天的方式来让 AstrBot 创建未来任务,而不必在此添加。",
"runOnce": "一次性任务",
"name": "任务名称",
"note": "任务说明",
@@ -2,11 +2,11 @@
"title": "插件管理",
"subtitle": "管理和配置系统插件",
"tabs": {
"installedPlugins": "已安装的插件",
"installedMcpServers": "已安装的 MCP 服务器",
"installedPlugins": "AstrBot 插件",
"market": "AstrBot 插件市场",
"installedMcpServers": "MCP",
"skills": "Skills",
"handlersOperation": "管理行为",
"market": "插件市场"
"handlersOperation": "管理行为"
},
"search": {
"placeholder": "搜索插件...",
@@ -210,7 +210,9 @@
"deleteTitle": "删除确认",
"deleteMessage": "确定要删除该 Skill 吗?",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败"
"deleteFailed": "删除失败",
"runtimeNoneWarning": "Computer Use 运行环境为无,Skills 可能无法正确被 Agent 运行,因为没有启用运行环境。",
"runtimeHint": "需要在配置的 “使用电脑能力” 中将运行环境设置为 “local” 或 “sandbox” 才能让 AstrBot 正常使用你提供的 Skills。"
},
"card": {
"actions": {
@@ -49,6 +49,7 @@
"loadingSkills": "正在加载 Skills...",
"allSkillsAvailable": "使用所有可用 Skills",
"noSkillsSelected": "未选择任何 Skills",
"skillsRuntimeNoneWarning": "Computer Use 运行环境为无,Skills 可能无法正确被 Agent 运行,因为没有启用运行环境。",
"createInFolder": "将在「{folder}」中创建",
"rootFolder": "全部人格"
},
@@ -16,6 +16,16 @@
"custom": "自定义"
}
},
"theme": {
"title": "主题",
"subtitle": "自定义主题主色与辅助色。修改后立即生效,并保存在浏览器本地。",
"customize": {
"title": "主题颜色",
"primary": "主色",
"secondary": "辅助色",
"reset": "恢复默认"
}
},
"system": {
"title": "系统",
"restart": {
@@ -119,4 +129,4 @@
"ftpHint": "对于较大的备份文件,也可以通过 FTP/SFTP 等方式直接上传到 data/backups 目录"
}
}
}
}
@@ -1,7 +1,7 @@
{
"page": {
"title": "SubAgent 编排",
"beta": "Beta",
"beta": "实验性",
"subtitle": "主 LLM 只负责聊天与分派(handoff),工具挂载在各个 SubAgent 上。"
},
"actions": {
@@ -35,8 +35,8 @@
"nameHint": "建议使用英文小写+下划线,且全局唯一",
"providerLabel": "Chat Provider(可选)",
"providerHint": "留空表示跟随全局默认 provider。",
"personaLabel": "选择 Persona",
"personaHint": "SubAgent 将直接继承所选 Persona 的系统设定与工具。",
"personaLabel": "选择人格设定",
"personaHint": "SubAgent 将直接继承所选 Persona 的系统设定与工具。在人格设定页管理和新建人格。",
"descriptionLabel": "对主 LLM 的描述(用于决定是否 handoff",
"descriptionHint": "这段会作为 transfer_to_* 工具的描述给主 LLM 看,建议简短明确。"
},
@@ -3,5 +3,8 @@
"autoScroll": {
"enabled": "自动滚动:开",
"disabled": "自动滚动:关"
}
},
"hint": "当前仅记录部分 AstrBot 主 Agent 的模型调用路径,后续会不断完善。",
"recording": "记录中",
"paused": "已暂停"
}
@@ -319,7 +319,7 @@ const changeLanguage = async (langCode: string) => {
</script>
<template>
<v-app-bar elevation="0" height="55">
<v-app-bar elevation="0" height="50" class="top-header">
<!-- 桌面端 menu 按钮 - 仅在 bot 模式下显示 -->
<v-btn v-if="customizer.viewMode === 'bot' && useCustomizerStore().uiTheme === 'PurpleTheme'" style="margin-left: 16px;"
@@ -2,23 +2,41 @@
import { useI18n } from '@/i18n/composables';
import { useCustomizerStore } from '@/stores/customizer';
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps({ item: Object, level: Number });
const { t } = useI18n();
const customizer = useCustomizerStore();
const route = useRoute();
const router = useRouter();
const itemStyle = computed(() => {
const lvl = props.level ?? 0;
const indent = customizer.mini_sidebar ? '0px' : `${lvl * 24}px`;
return { '--indent-padding': indent };
});
const handleGroupClick = () => {
if (!props.item || props.item.type === 'external' || !props.item.to) return;
router.push(props.item.to);
};
const isItemActive = computed(() => {
if (!props.item || props.item.type === 'external' || !props.item.to) return false;
if (typeof props.item.to !== 'string') return false;
if (props.item.to.includes('#')) {
const [path, hash] = props.item.to.split('#');
return route.path === path && route.hash === `#${hash}`;
}
return route.path === props.item.to;
});
</script>
<template>
<v-list-group v-if="item.children" :value="item.title" :class="{ 'group-bordered': customizer.mini_sidebar }">
<template v-slot:activator="{ props }">
<v-list-item v-bind="props" rounded class="mb-1" color="secondary" :prepend-icon="item.icon"
:style="{ '--indent-padding': '0px' }">
:style="{ '--indent-padding': '0px' }" @click="handleGroupClick">
<v-list-item-title style="font-size: 14px; font-weight: 500; line-height: 1.2; word-break: break-word;">
{{ t(item.title) }}
</v-list-item-title>
@@ -31,8 +49,9 @@ const itemStyle = computed(() => {
</template>
</v-list-group>
<v-list-item v-else :to="item.type === 'external' ? '' : item.to" :href="item.type === 'external' ? item.to : ''" rounded
class="mb-1" color="secondary" :disabled="item.disabled" :target="item.type === 'external' ? '_blank' : ''" :style="itemStyle">
<v-list-item v-else :to="item.type === 'external' ? '' : item.to" :href="item.type === 'external' ? item.to : ''"
:active="isItemActive" rounded class="mb-1" color="secondary" :disabled="item.disabled"
:target="item.type === 'external' ? '_blank' : ''" :style="itemStyle">
<template v-slot:prepend>
<v-icon v-if="item.icon" :size="item.iconSize" class="hide-menu" :icon="item.icon"></v-icon>
</template>
@@ -56,4 +75,4 @@ const itemStyle = computed(() => {
border-radius: 8px;
background: rgba(var(--v-theme-borderLight), 0.04);
}
</style>
</style>
@@ -253,16 +253,19 @@ function openChangelogDialog() {
</template>
</v-list>
<div class="sidebar-footer" v-if="!customizer.mini_sidebar">
<v-btn style="margin-bottom: 8px;" size="small" variant="tonal" color="primary" to="/settings">
🔧 {{ t('core.navigation.settings') }}
<v-btn class="sidebar-footer-btn" size="small" variant="tonal" color="primary" to="/settings" prepend-icon="mdi-cog">
{{ t('core.navigation.settings') }}
</v-btn>
<v-btn style="margin-bottom: 8px;" size="small" variant="plain" @click="openChangelogDialog">
📝 {{ t('core.navigation.changelog') }}
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-note-text-outline"
@click="openChangelogDialog">
{{ t('core.navigation.changelog') }}
</v-btn>
<v-btn style="margin-bottom: 8px;" size="small" variant="plain" @click="toggleIframe">
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-book-open-variant"
@click="toggleIframe">
{{ t('core.navigation.documentation') }}
</v-btn>
<v-btn style="margin-bottom: 8px;" size="small" variant="plain" @click="openIframeLink('https://github.com/AstrBotDevs/AstrBot')">
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-github"
@click="openIframeLink('https://github.com/AstrBotDevs/AstrBot')">
{{ t('core.navigation.github') }}
<v-chip
v-if="starCount"
@@ -366,4 +369,4 @@ function openChangelogDialog() {
.leftSidebar .v-navigation-drawer__content {
position: relative;
}
</style>
</style>
@@ -36,7 +36,34 @@ const sidebarItem: menu[] = [
{
title: 'core.navigation.extension',
icon: 'mdi-puzzle',
to: '/extension'
to: '/extension#installed',
children: [
{
title: 'core.navigation.extensionTabs.installed',
icon: 'mdi-puzzle',
to: '/extension#installed'
},
{
title: 'core.navigation.extensionTabs.market',
icon: 'mdi-store',
to: '/extension#market'
},
{
title: 'core.navigation.extensionTabs.mcp',
icon: 'mdi-server-network',
to: '/extension#mcp'
},
{
title: 'core.navigation.extensionTabs.skills',
icon: 'mdi-lightning-bolt',
to: '/extension#skills'
},
{
title: 'core.navigation.extensionTabs.components',
icon: 'mdi-wrench',
to: '/extension#components'
}
]
},
{
title: 'core.navigation.knowledgeBase',
+27 -1
View File
@@ -30,6 +30,19 @@ setupI18n().then(() => {
import('./stores/customizer').then(({ useCustomizerStore }) => {
const customizer = useCustomizerStore(pinia);
vuetify.theme.global.name.value = customizer.uiTheme;
const storedPrimary = localStorage.getItem('themePrimary');
const storedSecondary = localStorage.getItem('themeSecondary');
if (storedPrimary || storedSecondary) {
const themes = vuetify.theme.themes.value;
['PurpleTheme', 'PurpleThemeDark'].forEach((name) => {
const theme = themes[name];
if (!theme?.colors) return;
if (storedPrimary) theme.colors.primary = storedPrimary;
if (storedSecondary) theme.colors.secondary = storedSecondary;
if (storedPrimary && theme.colors.darkprimary) theme.colors.darkprimary = storedPrimary;
if (storedSecondary && theme.colors.darksecondary) theme.colors.darksecondary = storedSecondary;
});
}
});
}).catch(error => {
console.error('❌ 新i18n系统初始化失败:', error);
@@ -49,6 +62,19 @@ setupI18n().then(() => {
import('./stores/customizer').then(({ useCustomizerStore }) => {
const customizer = useCustomizerStore(pinia);
vuetify.theme.global.name.value = customizer.uiTheme;
const storedPrimary = localStorage.getItem('themePrimary');
const storedSecondary = localStorage.getItem('themeSecondary');
if (storedPrimary || storedSecondary) {
const themes = vuetify.theme.themes.value;
['PurpleTheme', 'PurpleThemeDark'].forEach((name) => {
const theme = themes[name];
if (!theme?.colors) return;
if (storedPrimary) theme.colors.primary = storedPrimary;
if (storedSecondary) theme.colors.secondary = storedSecondary;
if (storedPrimary && theme.colors.darkprimary) theme.colors.darkprimary = storedPrimary;
if (storedSecondary && theme.colors.darksecondary) theme.colors.darksecondary = storedSecondary;
});
}
});
});
@@ -79,4 +105,4 @@ loader.config({
paths: {
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs',
},
})
})
@@ -4,6 +4,10 @@ html {
.v-main {
margin-right: 20px;
}
.top-header {
border-bottom: 1px solid rgba(var(--v-theme-borderLight), 0.5);
}
@media (max-width: 1279px) {
.v-main {
margin: 0 10px;
+10 -1
View File
@@ -1,6 +1,7 @@
/*This is for the logo*/
.leftSidebar {
border: 0px;
border-right: 1px solid rgba(var(--v-theme-borderLight), 0.45);
box-shadow: none !important;
}
.listitem {
@@ -16,6 +17,9 @@
color: rgb(var(--v-theme-secondary));
}
}
.v-list-item--density-default.v-list-item--one-line {
min-height: 40px;
}
}
// 深色主题下的侧边栏悬停和选中样式
@@ -50,7 +54,7 @@
}
}
.v-list-item--density-default.v-list-item--one-line {
min-height: 42px;
min-height: 40px;
}
.leftPadding {
margin-left: 4px;
@@ -113,6 +117,11 @@
max-width: 180px;
margin-bottom: 8px !important;
}
.sidebar-footer-btn {
justify-content: flex-start;
text-align: left;
}
}
}
}
+2 -2
View File
@@ -8,8 +8,8 @@ const PurpleTheme: ThemeTypes = {
'carousel-control-size': 10
},
colors: {
primary: '#1e88e5',
secondary: '#5e35b1',
primary: '#3c96ca',
secondary: '#2288b7',
info: '#03c9d7',
success: '#00c853',
accent: '#FFAB91',
+1 -1
View File
@@ -58,7 +58,7 @@ export function resolveSidebarItems(defaultItems, customization, options = {}) {
// 收集所有条目,按 title 建索引
defaultItems.forEach(item => {
if (item.children) {
if (item.children && item.title === 'core.navigation.groups.more') {
item.children.forEach(child => {
all.set(child.title, cloneItems ? { ...child } : child);
defaultMore.push(child.title);
+13 -2
View File
@@ -10,7 +10,18 @@ const { tm } = useModuleI18n('features/console');
<div style="height: 100%;">
<div
style="background-color: var(--v-theme-surface); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px; display: flex; flex-direction: row; align-items: center; justify-content: space-between;">
<h4>{{ tm('title') }}</h4>
<div>
<h4>{{ tm('title') }}</h4>
<v-alert
type="info"
variant="tonal"
density="compact"
class="mt-2"
style="max-width: 600px;"
>
{{ tm('debugHint.text') }}
</v-alert>
</div>
<div class="d-flex align-center">
<v-switch
v-model="autoScrollEnabled"
@@ -111,4 +122,4 @@ export default {
.fade-in {
animation: fadeIn 0.2s ease-in-out;
}
</style>
</style>
+31 -49
View File
@@ -28,17 +28,13 @@
<v-alert v-if="!jobs.length && !loading" type="info" variant="tonal">{{ tm('table.empty') }}</v-alert>
<v-data-table
:items="jobs"
:headers="headers"
:loading="loading"
item-key="job_id"
density="comfortable"
class="elevation-0"
>
<v-data-table :items="jobs" :headers="headers" :loading="loading" item-key="job_id" density="comfortable"
class="elevation-0">
<template #item.name="{ item }">
<div class="font-weight-medium">{{ item.name }}</div>
<div class="text-caption text-medium-emphasis">{{ item.description }}</div>
<div class="py-4">
<div class="font-weight-medium">{{ item.name }}</div>
<div class="text-caption text-medium-emphasis">{{ item.description }}</div>
</div>
</template>
<template #item.type="{ item }">
<v-chip size="small" :color="item.run_once ? 'orange' : 'primary'" variant="tonal">
@@ -52,20 +48,18 @@
<div class="text-caption text-medium-emphasis">{{ item.timezone || tm('table.timezoneLocal') }}</div>
</div>
</template>
<template #item.session="{ item }">
<div>{{ item.session || tm('table.notAvailable') }}</div>
</template>
<template #item.next_run_time="{ item }">{{ formatTime(item.next_run_time) }}</template>
<template #item.last_run_at="{ item }">{{ formatTime(item.last_run_at) }}</template>
<template #item.note="{ item }">{{ item.note || tm('table.notAvailable') }}</template>
<template #item.actions="{ item }">
<div class="d-flex" style="gap: 8px;">
<v-switch
v-model="item.enabled"
inset
density="compact"
hide-details
color="primary"
@change="toggleJob(item)"
/>
<v-btn size="small" variant="text" color="primary" @click="deleteJob(item)">{{ tm('actions.delete') }}</v-btn>
<v-switch v-model="item.enabled" inset density="compact" hide-details color="primary"
@change="toggleJob(item)" />
<v-btn size="small" variant="text" color="primary" @click="deleteJob(item)">{{ tm('actions.delete')
}}</v-btn>
</div>
</template>
</v-data-table>
@@ -79,43 +73,26 @@
<v-dialog v-model="createDialog" max-width="560">
<v-card>
<v-card-title class="text-h6">{{ tm('form.title') }}</v-card-title>
<v-card-subtitle class="text-body-2 text-medium-emphasis">
{{ tm('form.chatHint') }}
</v-card-subtitle>
<v-card-text>
<v-switch v-model="newJob.run_once" :label="tm('form.runOnce')" inset color="primary" hide-details />
<v-text-field v-model="newJob.name" :label="tm('form.name')" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.note" :label="tm('form.note')" variant="outlined" density="comfortable" />
<v-text-field
v-if="!newJob.run_once"
v-model="newJob.cron_expression"
:label="tm('form.cron')"
:placeholder="tm('form.cronPlaceholder')"
variant="outlined"
density="comfortable"
/>
<v-text-field
v-else
v-model="newJob.run_at"
:label="tm('form.runAt')"
type="datetime-local"
variant="outlined"
density="comfortable"
/>
<v-text-field
v-model="newJob.session"
:label="tm('form.session')"
variant="outlined"
density="comfortable"
/>
<v-text-field
v-model="newJob.timezone"
:label="tm('form.timezone')"
variant="outlined"
density="comfortable"
/>
<v-text-field v-if="!newJob.run_once" v-model="newJob.cron_expression" :label="tm('form.cron')"
:placeholder="tm('form.cronPlaceholder')" variant="outlined" density="comfortable" />
<v-text-field v-else v-model="newJob.run_at" :label="tm('form.runAt')" type="datetime-local"
variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.session" :label="tm('form.session')" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.timezone" :label="tm('form.timezone')" variant="outlined"
density="comfortable" />
<v-switch v-model="newJob.enabled" :label="tm('form.enabled')" inset color="primary" hide-details />
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="createDialog = false">{{ tm('actions.cancel') }}</v-btn>
<v-btn variant="tonal" color="primary" :loading="creating" @click="createJob">{{ tm('actions.submit') }}</v-btn>
<v-btn variant="tonal" color="primary" :loading="creating" @click="createJob">{{ tm('actions.submit')
}}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -155,6 +132,7 @@ const headers = computed(() => [
{ title: tm('table.headers.name'), key: 'name', minWidth: '200px' },
{ title: tm('table.headers.type'), key: 'type', width: 110 },
{ title: tm('table.headers.cron'), key: 'cron_expression', minWidth: '160px' },
{ title: tm('table.headers.session'), key: 'session', minWidth: '200px' },
{ title: tm('table.headers.nextRun'), key: 'next_run_time', minWidth: '160px' },
{ title: tm('table.headers.lastRun'), key: 'last_run_at', minWidth: '160px' },
{ title: tm('table.headers.note'), key: 'note', minWidth: '220px' },
@@ -189,7 +167,11 @@ async function loadJobs() {
try {
const res = await axios.get('/api/cron/jobs')
if (res.data.status === 'ok') {
jobs.value = Array.isArray(res.data.data) ? res.data.data : []
const data = Array.isArray(res.data.data) ? res.data.data : []
jobs.value = data.map((job: any) => ({
...job,
session: job?.payload?.session || job?.session || ''
}))
} else {
toast(res.data.message || tm('messages.loadFailed'), 'error')
}
+54 -6
View File
@@ -15,12 +15,13 @@ import { useI18n, useModuleI18n } from "@/i18n/composables";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
import { ref, computed, onMounted, reactive, watch } from "vue";
import { useRouter } from "vue-router";
import { useRoute, useRouter } from "vue-router";
const commonStore = useCommonStore();
const { t } = useI18n();
const { tm } = useModuleI18n("features/extension");
const router = useRouter();
const route = useRoute();
const getSelectedGitHubProxy = () => {
if (typeof window === "undefined" || !window.localStorage) return "";
@@ -54,6 +55,23 @@ const handleConflictConfirm = () => {
const fileInput = ref(null);
const activeTab = ref("installed");
const validTabs = ["installed", "market", "mcp", "skills", "components"];
const isValidTab = (tab) => validTabs.includes(tab);
const getLocationHash = () =>
typeof window !== "undefined" ? window.location.hash : "";
const extractTabFromHash = (hash) => {
const lastHashIndex = (hash || "").lastIndexOf("#");
if (lastHashIndex === -1) return "";
return hash.slice(lastHashIndex + 1);
};
const syncTabFromHash = (hash) => {
const tab = extractTabFromHash(hash);
if (isValidTab(tab)) {
activeTab.value = tab;
return true;
}
return false;
};
const extension_data = reactive({
data: [],
message: "",
@@ -995,6 +1013,11 @@ const refreshPluginMarket = async () => {
//
onMounted(async () => {
if (!syncTabFromHash(getLocationHash())) {
if (typeof window !== "undefined") {
window.location.hash = `#${activeTab.value}`;
}
}
await getExtensions();
//
@@ -1051,6 +1074,29 @@ watch(isListView, (newVal) => {
localStorage.setItem("pluginListViewMode", String(newVal));
}
});
watch(
() => route.fullPath,
() => {
const tab = extractTabFromHash(getLocationHash());
if (isValidTab(tab) && tab !== activeTab.value) {
activeTab.value = tab;
}
},
);
watch(activeTab, (newTab) => {
if (!isValidTab(newTab)) return;
const currentTab = extractTabFromHash(getLocationHash());
if (currentTab === newTab) return;
const hash = getLocationHash();
const lastHashIndex = hash.lastIndexOf("#");
const nextHash =
lastHashIndex > 0 ? `${hash.slice(0, lastHashIndex)}#${newTab}` : `#${newTab}`;
if (typeof window !== "undefined") {
window.location.hash = nextHash;
}
});
</script>
<template>
@@ -1067,6 +1113,10 @@ watch(isListView, (newVal) => {
<v-icon class="mr-2">mdi-puzzle</v-icon>
{{ tm("tabs.installedPlugins") }}
</v-tab>
<v-tab value="market">
<v-icon class="mr-2">mdi-store</v-icon>
{{ tm("tabs.market") }}
</v-tab>
<v-tab value="mcp">
<v-icon class="mr-2">mdi-server-network</v-icon>
{{ tm("tabs.installedMcpServers") }}
@@ -1075,10 +1125,6 @@ watch(isListView, (newVal) => {
<v-icon class="mr-2">mdi-lightning-bolt</v-icon>
{{ tm("tabs.skills") }}
</v-tab>
<v-tab value="market">
<v-icon class="mr-2">mdi-store</v-icon>
{{ tm("tabs.market") }}
</v-tab>
<v-tab value="components">
<v-icon class="mr-2">mdi-wrench</v-icon>
{{ tm("tabs.handlersOperation") }}
@@ -2433,7 +2479,9 @@ watch(isListView, (newVal) => {
></v-progress-linear>
</div>
<div class="v-card-title text-h5">{{ tm("dialogs.install.title") }}</div>
<v-card-title class="text-h3 pa-4 pb-0 pl-6">
{{ tm("dialogs.install.title") }}
</v-card-title>
<div class="v-card-text">
<v-tabs v-model="uploadTab" color="primary">
+89 -2
View File
@@ -15,6 +15,41 @@
<SidebarCustomizer></SidebarCustomizer>
</v-list-item>
<v-list-subheader>{{ tm('theme.title') }}</v-list-subheader>
<v-list-item :subtitle="tm('theme.subtitle')" :title="tm('theme.customize.title')">
<v-row class="mt-2" dense>
<v-col cols="4" sm="2">
<v-text-field
v-model="primaryColor"
type="color"
:label="tm('theme.customize.primary')"
hide-details
variant="outlined"
density="compact"
style="max-width: 220px;"
/>
</v-col>
<v-col cols="4" sm="2 ">
<v-text-field
v-model="secondaryColor"
type="color"
:label="tm('theme.customize.secondary')"
hide-details
variant="outlined"
density="compact"
style="max-width: 220px;"
/>
</v-col>
<v-col cols="12">
<v-btn size="small" variant="tonal" color="primary" @click="resetThemeColors">
<v-icon class="mr-2">mdi-restore</v-icon>
{{ tm('theme.customize.reset') }}
</v-btn>
</v-col>
</v-row>
</v-list-item>
<v-list-subheader>{{ tm('system.title') }}</v-list-subheader>
<v-list-item :subtitle="tm('system.backup.subtitle')" :title="tm('system.backup.title')">
@@ -42,7 +77,7 @@
</template>
<script setup>
import { ref } from 'vue';
import { ref, watch } from 'vue';
import axios from 'axios';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ProxySelector from '@/components/shared/ProxySelector.vue';
@@ -50,8 +85,52 @@ import MigrationDialog from '@/components/shared/MigrationDialog.vue';
import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue';
import BackupDialog from '@/components/shared/BackupDialog.vue';
import { useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import { PurpleTheme } from '@/theme/LightTheme';
const { tm } = useModuleI18n('features/settings');
const theme = useTheme();
const getStoredColor = (key, fallback) => {
const stored = typeof window !== 'undefined' ? localStorage.getItem(key) : null;
return stored || fallback;
};
const primaryColor = ref(getStoredColor('themePrimary', PurpleTheme.colors.primary));
const secondaryColor = ref(getStoredColor('themeSecondary', PurpleTheme.colors.secondary));
const resolveThemes = () => {
if (theme?.themes?.value) return theme.themes.value;
if (theme?.global?.themes?.value) return theme.global.themes.value;
return null;
};
const applyThemeColors = (primary, secondary) => {
const themes = resolveThemes();
if (!themes) return;
['PurpleTheme', 'PurpleThemeDark'].forEach((name) => {
const themeDef = themes[name];
if (!themeDef?.colors) return;
if (primary) themeDef.colors.primary = primary;
if (secondary) themeDef.colors.secondary = secondary;
if (primary && themeDef.colors.darkprimary) themeDef.colors.darkprimary = primary;
if (secondary && themeDef.colors.darksecondary) themeDef.colors.darksecondary = secondary;
});
};
applyThemeColors(primaryColor.value, secondaryColor.value);
watch(primaryColor, (value) => {
if (!value) return;
localStorage.setItem('themePrimary', value);
applyThemeColors(value, secondaryColor.value);
});
watch(secondaryColor, (value) => {
if (!value) return;
localStorage.setItem('themeSecondary', value);
applyThemeColors(primaryColor.value, value);
});
const wfr = ref(null);
const migrationDialog = ref(null);
@@ -81,4 +160,12 @@ const openBackupDialog = () => {
backupDialog.value.open();
}
}
</script>
const resetThemeColors = () => {
primaryColor.value = PurpleTheme.colors.primary;
secondaryColor.value = PurpleTheme.colors.secondary;
localStorage.removeItem('themePrimary');
localStorage.removeItem('themeSecondary');
applyThemeColors(primaryColor.value, secondaryColor.value);
};
</script>
+96 -2
View File
@@ -1,13 +1,72 @@
<script setup>
import TraceDisplayer from '@/components/shared/TraceDisplayer.vue';
import { useModuleI18n } from '@/i18n/composables';
import { ref, onMounted } from 'vue';
import axios from 'axios';
const { tm } = useModuleI18n('features/trace');
const traceEnabled = ref(true);
const loading = ref(false);
const traceDisplayerKey = ref(0);
const fetchTraceSettings = async () => {
try {
const res = await axios.get('/api/trace/settings');
if (res.data?.status === 'ok') {
traceEnabled.value = res.data.data?.trace_enable ?? true;
}
} catch (err) {
console.error('Failed to fetch trace settings:', err);
}
};
const updateTraceSettings = async () => {
loading.value = true;
try {
await axios.post('/api/trace/settings', {
trace_enable: traceEnabled.value
});
// Refresh the TraceDisplayer component to reconnect SSE
traceDisplayerKey.value += 1;
} catch (err) {
console.error('Failed to update trace settings:', err);
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchTraceSettings();
});
</script>
<template>
<div style="height: 100%;">
<TraceDisplayer />
<div style="height: 100%; display: flex; flex-direction: column;">
<div class="trace-header">
<div class="trace-info">
<v-icon size="small" color="info" class="mr-2">mdi-information-outline</v-icon>
<span class="trace-hint">{{ tm('hint') }}</span>
</div>
<div class="trace-controls">
<v-switch
v-model="traceEnabled"
:loading="loading"
:disabled="loading"
color="primary"
hide-details
density="compact"
@update:model-value="updateTraceSettings"
>
<template #label>
<span class="switch-label">{{ traceEnabled ? tm('recording') : tm('paused') }}</span>
</template>
</v-switch>
</div>
</div>
<div style="flex: 1; min-height: 0;">
<TraceDisplayer :key="traceDisplayerKey" />
</div>
</div>
</template>
@@ -19,3 +78,38 @@ export default {
}
};
</script>
<style scoped>
.trace-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(59, 130, 246, 0.05);
border-bottom: 1px solid rgba(59, 130, 246, 0.1);
border-radius: 8px 8px 0 0;
margin-bottom: 8px;
}
.trace-info {
display: flex;
align-items: center;
}
.trace-hint {
font-size: 13px;
color: #6b7280;
}
.trace-controls {
display: flex;
align-items: center;
gap: 8px;
}
.switch-label {
font-size: 13px;
color: #4b5563;
white-space: nowrap;
}
</style>

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