Compare commits

...

9 Commits

Author SHA1 Message Date
Soulter eafb339281 feat(logging): add file and trace logging configuration options 2026-01-30 11:54:08 +08:00
Soulter f03dd87502 fix(log): increase log cache size from 200 to 500 2026-01-30 10:53:15 +08:00
Soulter 6e475074a4 feat: trace 2026-01-29 20:56:45 +08:00
Helian Nuits 2aa0986295 fix(db): using lambda expression to ensure updated_at field (#4730)
* fix(db): 使用 lambda 表达式确保 updated_at 字段正确更新

updated_at 字段原先在 sa_column_kwargs["onupdate"] 中直接使用了 datetime.now(),导致时间戳仅在模块导入时被计算一次,之后不再变化,结果所有记录的更新时间都被设成了程序启动时间。

本次修改将时间戳生成逻辑封装进 lambda 表达式,使 SQLAlchemy 在每次更新记录时才惰性求值,从而保证时间戳实时更新。

* refactor(db): 根据建议引入 TimestampMixin 统一时间戳定义,提取 `created_at`/`updated_at` 至 [TimestampMixin]
2026-01-29 19:07:21 +08:00
Soulter 34c6ceb67c fix(docs): update feature description to include 'Skills' in README files 2026-01-29 17:22:48 +08:00
Soulter 906877cbe6 feat(i18n): add localized message for tool usage in chat 2026-01-29 16:29:44 +08:00
Soulter 609180022e feat(chat): refactor message rendering and introduce ToolCallItem component 2026-01-29 16:07:57 +08:00
Soulter 49c087a141 docs: replace demo banner in readme
Updated the image in the README file.
2026-01-29 12:17:11 +08:00
Soulter 70f12cd686 docs(readme): update language links and enhance feature descriptions 2026-01-29 12:09:38 +08:00
31 changed files with 1449 additions and 268 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ AstrBot 是一个开源的一站式 Agent 聊天机器人平台,可接入主
## 主要功能 ## 主要功能
1. 💯 免费 & 开源。 1. 💯 免费 & 开源。
1. ✨ AI 大模型对话,多模态,Agent,MCP,知识库,人格设定,自动压缩对话。 1. ✨ AI 大模型对话,多模态,Agent,MCP,Skills知识库,人格设定,自动压缩对话。
2. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。 2. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
2. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。 2. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
3. 📦 插件扩展,已有近 800 个插件可一键安装。 3. 📦 插件扩展,已有近 800 个插件可一键安装。
+28 -19
View File
@@ -1,9 +1,14 @@
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9) ![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
</p>
<div align="center"> <div align="center">
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
<br> <br>
<div> <div>
@@ -14,22 +19,17 @@
<br> <br>
<div> <div>
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?style=for-the-badge&color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest"> <img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
<img src="https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&color=76bad9" alt="python"> <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?style=for-the-badge&color=76bad9"/></a> <img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a> <a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a> <a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20plugins&style=for-the-badge&label=Marketplace&cacheSeconds=3600"> <img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20plugins&label=Marketplace&cacheSeconds=3600">
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
</div> </div>
<br> <br>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
<a href="https://astrbot.app/">Documentation</a> <a href="https://astrbot.app/">Documentation</a>
<a href="https://blog.astrbot.app/">Blog</a> <a href="https://blog.astrbot.app/">Blog</a>
<a href="https://astrbot.featurebase.app/roadmap">Roadmap</a> <a href="https://astrbot.featurebase.app/roadmap">Roadmap</a>
@@ -38,17 +38,19 @@
AstrBot is an open-source all-in-one Agent chatbot platform that integrates with mainstream instant messaging apps. It provides reliable and scalable conversational AI infrastructure for individuals, developers, and teams. Whether you're building a personal AI companion, intelligent customer service, automation assistant, or enterprise knowledge base, AstrBot enables you to quickly build production-ready AI applications within your IM platform workflows. AstrBot is an open-source all-in-one Agent chatbot platform that integrates with mainstream instant messaging apps. It provides reliable and scalable conversational AI infrastructure for individuals, developers, and teams. Whether you're building a personal AI companion, intelligent customer service, automation assistant, or enterprise knowledge base, AstrBot enables you to quickly build production-ready AI applications within your IM platform workflows.
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" /> ![070d50ba43ea3c96980787127bbbe552](https://github.com/user-attachments/assets/6fe147c5-68d9-4f47-a8de-252e63fdcbd8)
## Key Features ## Key Features
1. 💯 Free & Open Source. 1. 💯 Free & Open Source.
2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Knowledge Base, Persona Settings. 2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Skills, Knowledge Base, Persona Settings, Auto Context Compression.
3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze and other agent platforms. 3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze, and other agent platforms.
4. 🌐 Multi-Platform: QQ, WeChat Work, Feishu, DingTalk, WeChat Official Accounts, Telegram, Slack, and [more](#supported-messaging-platforms). 4. 🌐 Multi-Platform: QQ, WeChat Work, Feishu, DingTalk, WeChat Official Accounts, Telegram, Slack, and [more](#supported-messaging-platforms).
5. 📦 Plugin Extensions with nearly 800 plugins available for one-click installation. 5. 📦 Plugin Extensions with nearly 800 plugins available for one-click installation.
6. 💻 WebUI Support. 6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) for isolated, safe execution of code, shell calls, and session-level resource reuse.
7. 🌐 Internationalization (i18n) Support. 7. 💻 WebUI Support.
8. 🌈 Web ChatUI Support with built-in agent sandbox and web search.
9. 🌐 Internationalization (i18n) Support.
## Quick Start ## Quick Start
@@ -208,6 +210,8 @@ pre-commit install
- Group 3: 630166526 - Group 3: 630166526
- Group 5: 822130018 - Group 5: 822130018
- Group 6: 753075035 - Group 6: 753075035
- Group 7: 743746109
- Group 8: 1030353265
- Developer Group: 975206796 - Developer Group: 975206796
### Telegram Group ### Telegram Group
@@ -243,4 +247,9 @@ Additionally, the birth of this project would not have been possible without the
</details> </details>
<div align="center">
_私は、高性能ですから!_ _私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div>
@@ -38,7 +38,12 @@ class ProcessLLMRequest:
req.func_tool.add_tool(LOCAL_PYTHON_TOOL) req.func_tool.add_tool(LOCAL_PYTHON_TOOL)
async def _ensure_persona( async def _ensure_persona(
self, req: ProviderRequest, cfg: dict, umo: str, platform_type: str self,
req: ProviderRequest,
cfg: dict,
umo: str,
platform_type: str,
event: AstrMessageEvent,
): ):
"""确保用户人格已加载""" """确保用户人格已加载"""
if not req.conversation: if not req.conversation:
@@ -121,6 +126,9 @@ class ProcessLLMRequest:
req.func_tool = toolset req.func_tool = toolset
else: else:
req.func_tool.merge(toolset) req.func_tool.merge(toolset)
event.trace.record(
"sel_persona", persona_id=persona_id, persona_toolset=toolset.names()
)
logger.debug(f"Tool set for persona {persona_id}: {toolset.names()}") logger.debug(f"Tool set for persona {persona_id}: {toolset.names()}")
async def _ensure_img_caption( async def _ensure_img_caption(
@@ -225,7 +233,7 @@ class ProcessLLMRequest:
# inject persona for this request # inject persona for this request
platform_type = event.get_platform_name() platform_type = event.get_platform_name()
await self._ensure_persona( await self._ensure_persona(
req, cfg, event.unified_msg_origin, platform_type req, cfg, event.unified_msg_origin, platform_type, event
) )
# image caption # image caption
+2
View File
@@ -20,6 +20,8 @@ astrbot_config = AstrBotConfig()
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img") t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
html_renderer = HtmlRenderer(t2i_base_url) html_renderer = HtmlRenderer(t2i_base_url)
logger = LogManager.GetLogger(log_name="astrbot") logger = LogManager.GetLogger(log_name="astrbot")
LogManager.configure_logger(logger, astrbot_config)
LogManager.configure_trace_logger(astrbot_config)
db_helper = SQLiteDatabase(DB_PATH) db_helper = SQLiteDatabase(DB_PATH)
# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中 # 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
sp = SharedPreferences(db_helper=db_helper) sp = SharedPreferences(db_helper=db_helper)
+48
View File
@@ -182,6 +182,12 @@ DEFAULT_CONFIG = {
}, },
"wake_prefix": ["/"], "wake_prefix": ["/"],
"log_level": "INFO", "log_level": "INFO",
"log_file_enable": False,
"log_file_path": "logs/astrbot.log",
"log_file_max_mb": 20,
"trace_log_enable": False,
"trace_log_path": "logs/astrbot.trace.log",
"trace_log_max_mb": 20,
"pip_install_arg": "", "pip_install_arg": "",
"pypi_index_url": "https://mirrors.aliyun.com/pypi/simple/", "pypi_index_url": "https://mirrors.aliyun.com/pypi/simple/",
"persona": [], # deprecated "persona": [], # deprecated
@@ -2321,6 +2327,18 @@ CONFIG_METADATA_2 = {
"type": "string", "type": "string",
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], "options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
}, },
"log_file_enable": {"type": "bool"},
"log_file_path": {"type": "string", "condition": {"log_file_enable": True}},
"log_file_max_mb": {"type": "int", "condition": {"log_file_enable": True}},
"trace_log_enable": {"type": "bool"},
"trace_log_path": {
"type": "string",
"condition": {"trace_log_enable": True},
},
"trace_log_max_mb": {
"type": "int",
"condition": {"trace_log_enable": True},
},
"t2i_strategy": { "t2i_strategy": {
"type": "string", "type": "string",
"options": ["remote", "local"], "options": ["remote", "local"],
@@ -3253,6 +3271,36 @@ CONFIG_METADATA_3_SYSTEM = {
"hint": "控制台输出日志的级别。", "hint": "控制台输出日志的级别。",
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], "options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
}, },
"log_file_enable": {
"description": "启用文件日志",
"type": "bool",
"hint": "开启后会将日志写入指定文件。",
},
"log_file_path": {
"description": "日志文件路径",
"type": "string",
"hint": "相对路径以 data 目录为基准,例如 logs/astrbot.log;支持绝对路径。",
},
"log_file_max_mb": {
"description": "日志文件大小上限 (MB)",
"type": "int",
"hint": "超过大小后自动轮转,默认 20MB。",
},
"trace_log_enable": {
"description": "启用 Trace 文件日志",
"type": "bool",
"hint": "将 Trace 事件写入独立文件(不影响控制台输出)。",
},
"trace_log_path": {
"description": "Trace 日志文件路径",
"type": "string",
"hint": "相对路径以 data 目录为基准,例如 logs/astrbot.trace.log;支持绝对路径。",
},
"trace_log_max_mb": {
"description": "Trace 日志大小上限 (MB)",
"type": "int",
"hint": "超过大小后自动轮转,默认 20MB。",
},
"pip_install_arg": { "pip_install_arg": {
"description": "pip 安装额外参数", "description": "pip 安装额外参数",
"type": "string", "type": "string",
+7 -3
View File
@@ -17,7 +17,7 @@ import traceback
from asyncio import Queue from asyncio import Queue
from astrbot.api import logger, sp from astrbot.api import logger, sp
from astrbot.core import LogBroker from astrbot.core import LogBroker, LogManager
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.config.default import VERSION from astrbot.core.config.default import VERSION
from astrbot.core.conversation_mgr import ConversationManager from astrbot.core.conversation_mgr import ConversationManager
@@ -80,9 +80,13 @@ class AstrBotCoreLifecycle:
# 初始化日志代理 # 初始化日志代理
logger.info("AstrBot v" + VERSION) logger.info("AstrBot v" + VERSION)
if os.environ.get("TESTING", ""): if os.environ.get("TESTING", ""):
logger.setLevel("DEBUG") # 测试模式下设置日志级别为 DEBUG LogManager.configure_logger(
logger, self.astrbot_config, override_level="DEBUG"
)
LogManager.configure_trace_logger(self.astrbot_config)
else: else:
logger.setLevel(self.astrbot_config["log_level"]) # 设置日志级别 LogManager.configure_logger(logger, self.astrbot_config)
LogManager.configure_trace_logger(self.astrbot_config)
await self.db.initialize() await self.db.initialize()
+19 -61
View File
@@ -6,6 +6,14 @@ from typing import TypedDict
from sqlmodel import JSON, Field, SQLModel, Text, UniqueConstraint from sqlmodel import JSON, Field, SQLModel, Text, UniqueConstraint
class TimestampMixin(SQLModel):
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)},
)
class PlatformStat(SQLModel, table=True): class PlatformStat(SQLModel, table=True):
"""This class represents the statistics of bot usage across different platforms. """This class represents the statistics of bot usage across different platforms.
@@ -30,7 +38,7 @@ class PlatformStat(SQLModel, table=True):
) )
class ConversationV2(SQLModel, table=True): class ConversationV2(TimestampMixin, SQLModel, table=True):
__tablename__: str = "conversations" __tablename__: str = "conversations"
inner_conversation_id: int | None = Field( inner_conversation_id: int | None = Field(
@@ -47,11 +55,7 @@ class ConversationV2(SQLModel, table=True):
platform_id: str = Field(nullable=False) platform_id: str = Field(nullable=False)
user_id: str = Field(nullable=False) user_id: str = Field(nullable=False)
content: list | None = Field(default=None, sa_type=JSON) content: list | None = Field(default=None, sa_type=JSON)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
title: str | None = Field(default=None, max_length=255) title: str | None = Field(default=None, max_length=255)
persona_id: str | None = Field(default=None) persona_id: str | None = Field(default=None)
token_usage: int = Field(default=0, nullable=False) token_usage: int = Field(default=0, nullable=False)
@@ -68,7 +72,7 @@ class ConversationV2(SQLModel, table=True):
) )
class PersonaFolder(SQLModel, table=True): class PersonaFolder(TimestampMixin, SQLModel, table=True):
"""Persona 文件夹,支持递归层级结构。 """Persona 文件夹,支持递归层级结构。
用于组织和管理多个 Persona,类似于文件系统的目录结构。 用于组织和管理多个 Persona,类似于文件系统的目录结构。
@@ -92,11 +96,6 @@ class PersonaFolder(SQLModel, table=True):
"""父文件夹IDNULL表示根目录""" """父文件夹IDNULL表示根目录"""
description: str | None = Field(default=None, sa_type=Text) description: str | None = Field(default=None, sa_type=Text)
sort_order: int = Field(default=0) sort_order: int = Field(default=0)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
@@ -106,7 +105,7 @@ class PersonaFolder(SQLModel, table=True):
) )
class Persona(SQLModel, table=True): class Persona(TimestampMixin, SQLModel, table=True):
"""Persona is a set of instructions for LLMs to follow. """Persona is a set of instructions for LLMs to follow.
It can be used to customize the behavior of LLMs. It can be used to customize the behavior of LLMs.
@@ -131,11 +130,6 @@ class Persona(SQLModel, table=True):
"""所属文件夹IDNULL 表示在根目录""" """所属文件夹IDNULL 表示在根目录"""
sort_order: int = Field(default=0) sort_order: int = Field(default=0)
"""排序顺序""" """排序顺序"""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
@@ -145,7 +139,7 @@ class Persona(SQLModel, table=True):
) )
class Preference(SQLModel, table=True): class Preference(TimestampMixin, SQLModel, table=True):
"""This class represents preferences for bots.""" """This class represents preferences for bots."""
__tablename__: str = "preferences" __tablename__: str = "preferences"
@@ -161,11 +155,6 @@ class Preference(SQLModel, table=True):
"""ID of the scope, such as 'global', 'umo', 'plugin_name'.""" """ID of the scope, such as 'global', 'umo', 'plugin_name'."""
key: str = Field(nullable=False) key: str = Field(nullable=False)
value: dict = Field(sa_type=JSON, nullable=False) value: dict = Field(sa_type=JSON, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
@@ -177,7 +166,7 @@ class Preference(SQLModel, table=True):
) )
class PlatformMessageHistory(SQLModel, table=True): class PlatformMessageHistory(TimestampMixin, SQLModel, table=True):
"""This class represents the message history for a specific platform. """This class represents the message history for a specific platform.
It is used to store messages that are not LLM-generated, such as user messages It is used to store messages that are not LLM-generated, such as user messages
@@ -198,14 +187,9 @@ class PlatformMessageHistory(SQLModel, table=True):
default=None, default=None,
) # Name of the sender in the platform ) # Name of the sender in the platform
content: dict = Field(sa_type=JSON, nullable=False) # a message chain list content: dict = Field(sa_type=JSON, nullable=False) # a message chain list
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
class PlatformSession(SQLModel, table=True): class PlatformSession(TimestampMixin, SQLModel, table=True):
"""Platform session table for managing user sessions across different platforms. """Platform session table for managing user sessions across different platforms.
A session represents a chat window for a specific user on a specific platform. A session represents a chat window for a specific user on a specific platform.
@@ -233,11 +217,6 @@ class PlatformSession(SQLModel, table=True):
"""Display name for the session""" """Display name for the session"""
is_group: int = Field(default=0, nullable=False) is_group: int = Field(default=0, nullable=False)
"""0 for private chat, 1 for group chat (not implemented yet)""" """0 for private chat, 1 for group chat (not implemented yet)"""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
@@ -247,7 +226,7 @@ class PlatformSession(SQLModel, table=True):
) )
class Attachment(SQLModel, table=True): class Attachment(TimestampMixin, SQLModel, table=True):
"""This class represents attachments for messages in AstrBot. """This class represents attachments for messages in AstrBot.
Attachments can be images, files, or other media types. Attachments can be images, files, or other media types.
@@ -269,11 +248,6 @@ class Attachment(SQLModel, table=True):
path: str = Field(nullable=False) # Path to the file on disk path: str = Field(nullable=False) # Path to the file on disk
type: str = Field(nullable=False) # Type of the file (e.g., 'image', 'file') type: str = Field(nullable=False) # Type of the file (e.g., 'image', 'file')
mime_type: str = Field(nullable=False) # MIME type of the file mime_type: str = Field(nullable=False) # MIME type of the file
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
@@ -283,7 +257,7 @@ class Attachment(SQLModel, table=True):
) )
class ChatUIProject(SQLModel, table=True): class ChatUIProject(TimestampMixin, SQLModel, table=True):
"""This class represents projects for organizing ChatUI conversations. """This class represents projects for organizing ChatUI conversations.
Projects allow users to group related conversations together. Projects allow users to group related conversations together.
@@ -310,11 +284,6 @@ class ChatUIProject(SQLModel, table=True):
"""Title of the project""" """Title of the project"""
description: str | None = Field(default=None, max_length=1000) description: str | None = Field(default=None, max_length=1000)
"""Description of the project""" """Description of the project"""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
@@ -338,7 +307,6 @@ class SessionProjectRelation(SQLModel, table=True):
"""Session ID from PlatformSession""" """Session ID from PlatformSession"""
project_id: str = Field(nullable=False, max_length=36) project_id: str = Field(nullable=False, max_length=36)
"""Project ID from ChatUIProject""" """Project ID from ChatUIProject"""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
@@ -348,7 +316,7 @@ class SessionProjectRelation(SQLModel, table=True):
) )
class CommandConfig(SQLModel, table=True): class CommandConfig(TimestampMixin, SQLModel, table=True):
"""Per-command configuration overrides for dashboard management.""" """Per-command configuration overrides for dashboard management."""
__tablename__ = "command_configs" # type: ignore __tablename__ = "command_configs" # type: ignore
@@ -368,14 +336,9 @@ class CommandConfig(SQLModel, table=True):
note: str | None = Field(default=None, sa_type=Text) note: str | None = Field(default=None, sa_type=Text)
extra_data: dict | None = Field(default=None, sa_type=JSON) extra_data: dict | None = Field(default=None, sa_type=JSON)
auto_managed: bool = Field(default=False, nullable=False) auto_managed: bool = Field(default=False, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
class CommandConflict(SQLModel, table=True): class CommandConflict(TimestampMixin, SQLModel, table=True):
"""Conflict tracking for duplicated command names.""" """Conflict tracking for duplicated command names."""
__tablename__ = "command_conflicts" # type: ignore __tablename__ = "command_conflicts" # type: ignore
@@ -392,11 +355,6 @@ class CommandConflict(SQLModel, table=True):
note: str | None = Field(default=None, sa_type=Text) note: str | None = Field(default=None, sa_type=Text)
extra_data: dict | None = Field(default=None, sa_type=JSON) extra_data: dict | None = Field(default=None, sa_type=JSON)
auto_generated: bool = Field(default=False, nullable=False) auto_generated: bool = Field(default=False, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
+1
View File
@@ -54,6 +54,7 @@ class EventBus:
event (AstrMessageEvent): 事件对象 event (AstrMessageEvent): 事件对象
""" """
event.trace.record("event_dispatch", config_name=conf_name)
# 如果有发送者名称: [平台名] 发送者名称/发送者ID: 消息概要 # 如果有发送者名称: [平台名] 发送者名称/发送者ID: 消息概要
if event.get_sender_name(): if event.get_sender_name():
logger.info( logger.info(
+189 -1
View File
@@ -27,13 +27,15 @@ import sys
import time import time
from asyncio import Queue from asyncio import Queue
from collections import deque from collections import deque
from logging.handlers import RotatingFileHandler
import colorlog import colorlog
from astrbot.core.config.default import VERSION from astrbot.core.config.default import VERSION
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
# 日志缓存大小 # 日志缓存大小
CACHED_SIZE = 200 CACHED_SIZE = 500
# 日志颜色配置 # 日志颜色配置
log_color_config = { log_color_config = {
"DEBUG": "green", "DEBUG": "green",
@@ -163,6 +165,9 @@ class LogManager:
提供了获取默认日志记录器logger和设置队列处理器的方法 提供了获取默认日志记录器logger和设置队列处理器的方法
""" """
_FILE_HANDLER_FLAG = "_astrbot_file_handler"
_TRACE_FILE_HANDLER_FLAG = "_astrbot_trace_file_handler"
@classmethod @classmethod
def GetLogger(cls, log_name: str = "default"): def GetLogger(cls, log_name: str = "default"):
"""获取指定名称的日志记录器logger """获取指定名称的日志记录器logger
@@ -266,3 +271,186 @@ class LogManager:
), ),
) )
logger.addHandler(handler) logger.addHandler(handler)
@classmethod
def _default_log_path(cls) -> str:
return os.path.join(get_astrbot_data_path(), "logs", "astrbot.log")
@classmethod
def _resolve_log_path(cls, configured_path: str | None) -> str:
if not configured_path:
return cls._default_log_path()
if os.path.isabs(configured_path):
return configured_path
return os.path.join(get_astrbot_data_path(), configured_path)
@classmethod
def _get_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]:
return [
handler
for handler in logger.handlers
if getattr(handler, cls._FILE_HANDLER_FLAG, False)
]
@classmethod
def _get_trace_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]:
return [
handler
for handler in logger.handlers
if getattr(handler, cls._TRACE_FILE_HANDLER_FLAG, False)
]
@classmethod
def _remove_file_handlers(cls, logger: logging.Logger):
for handler in cls._get_file_handlers(logger):
logger.removeHandler(handler)
try:
handler.close()
except Exception:
pass
@classmethod
def _remove_trace_file_handlers(cls, logger: logging.Logger):
for handler in cls._get_trace_file_handlers(logger):
logger.removeHandler(handler)
try:
handler.close()
except Exception:
pass
@classmethod
def _add_file_handler(
cls,
logger: logging.Logger,
file_path: str,
max_mb: int | None = None,
backup_count: int = 3,
trace: bool = False,
):
os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
max_bytes = 0
if max_mb and max_mb > 0:
max_bytes = max_mb * 1024 * 1024
if max_bytes > 0:
file_handler = RotatingFileHandler(
file_path,
maxBytes=max_bytes,
backupCount=backup_count,
encoding="utf-8",
)
else:
file_handler = logging.FileHandler(file_path, encoding="utf-8")
file_handler.setLevel(logger.level)
if trace:
formatter = logging.Formatter(
"[%(asctime)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
else:
formatter = logging.Formatter(
"[%(asctime)s] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s [%(filename)s:%(lineno)d]: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler.setFormatter(formatter)
setattr(
file_handler,
cls._TRACE_FILE_HANDLER_FLAG if trace else cls._FILE_HANDLER_FLAG,
True,
)
logger.addHandler(file_handler)
@classmethod
def configure_logger(
cls,
logger: logging.Logger,
config: dict | None,
override_level: str | None = None,
):
"""根据配置设置日志级别和文件日志。
Args:
logger: 需要配置的 logger
config: 配置字典
override_level: 若提供,将覆盖配置中的日志级别
"""
if not config:
return
level = override_level or config.get("log_level")
if level:
try:
logger.setLevel(level)
except Exception:
logger.setLevel(logging.INFO)
# 兼容旧版嵌套配置
if "log_file" in config:
file_conf = config.get("log_file") or {}
enable_file = bool(file_conf.get("enable", False))
file_path = file_conf.get("path")
max_mb = file_conf.get("max_mb")
else:
enable_file = bool(config.get("log_file_enable", False))
file_path = config.get("log_file_path")
max_mb = config.get("log_file_max_mb")
file_path = cls._resolve_log_path(file_path)
existing = cls._get_file_handlers(logger)
if not enable_file:
cls._remove_file_handlers(logger)
return
# 如果已有文件处理器且路径一致,则仅同步级别
if existing:
handler = existing[0]
base = getattr(handler, "baseFilename", "")
if base and os.path.abspath(base) == os.path.abspath(file_path):
handler.setLevel(logger.level)
return
cls._remove_file_handlers(logger)
cls._add_file_handler(logger, file_path, max_mb=max_mb)
@classmethod
def configure_trace_logger(cls, config: dict | None):
"""为 trace 事件配置独立的文件日志,不向控制台输出。"""
if not config:
return
enable = bool(
config.get("trace_log_enable")
or (config.get("log_file", {}) or {}).get("trace_enable", False)
)
path = config.get("trace_log_path")
max_mb = config.get("trace_log_max_mb")
if "log_file" in config:
legacy = config.get("log_file") or {}
path = path or legacy.get("trace_path")
max_mb = max_mb or legacy.get("trace_max_mb")
if not enable:
trace_logger = logging.getLogger("astrbot.trace")
cls._remove_trace_file_handlers(trace_logger)
return
file_path = cls._resolve_log_path(path or "logs/astrbot.trace.log")
trace_logger = logging.getLogger("astrbot.trace")
trace_logger.setLevel(logging.INFO)
trace_logger.propagate = False
existing = cls._get_trace_file_handlers(trace_logger)
if existing:
handler = existing[0]
base = getattr(handler, "baseFilename", "")
if base and os.path.abspath(base) == os.path.abspath(file_path):
handler.setLevel(trace_logger.level)
return
cls._remove_trace_file_handlers(trace_logger)
cls._add_file_handler(
trace_logger,
file_path,
max_mb=max_mb,
trace=True,
)
@@ -691,6 +691,17 @@ class InternalAgentSubStage(Stage):
if action_type == "live": if action_type == "live":
req.system_prompt += f"\n{LIVE_MODE_SYSTEM_PROMPT}\n" req.system_prompt += f"\n{LIVE_MODE_SYSTEM_PROMPT}\n"
event.trace.record(
"astr_agent_prepare",
system_prompt=req.system_prompt,
tools=req.func_tool.names() if req.func_tool else [],
stream=streaming_response,
chat_provider={
"id": provider.provider_config.get("id", ""),
"model": provider.get_model(),
},
)
await agent_runner.reset( await agent_runner.reset(
provider=provider, provider=provider,
request=req, request=req,
@@ -795,12 +806,20 @@ class InternalAgentSubStage(Stage):
): ):
yield yield
final_resp = agent_runner.get_final_llm_resp()
event.trace.record(
"astr_agent_complete",
stats=agent_runner.stats.to_dict(),
resp=final_resp.completion_text if final_resp else None,
)
# 检查事件是否被停止,如果被停止则不保存历史记录 # 检查事件是否被停止,如果被停止则不保存历史记录
if not event.is_stopped(): if not event.is_stopped():
await self._save_to_history( await self._save_to_history(
event, event,
req, req,
agent_runner.get_final_llm_resp(), final_resp,
agent_runner.run_context.messages, agent_runner.run_context.messages,
agent_runner.stats, agent_runner.stats,
) )
+2
View File
@@ -85,4 +85,6 @@ class PipelineScheduler:
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent): if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
await event.send(None) await event.send(None)
event.trace.record("event_end")
logger.debug("pipeline 执行完毕。") logger.debug("pipeline 执行完毕。")
@@ -4,6 +4,7 @@ import hashlib
import re import re
import uuid import uuid
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from time import time
from typing import Any from typing import Any
from astrbot import logger from astrbot import logger
@@ -22,6 +23,7 @@ from astrbot.core.message.message_event_result import MessageChain, MessageEvent
from astrbot.core.platform.message_type import MessageType from astrbot.core.platform.message_type import MessageType
from astrbot.core.provider.entities import ProviderRequest from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.utils.metrics import Metric from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.trace import TraceSpan
from .astrbot_message import AstrBotMessage, Group from .astrbot_message import AstrBotMessage, Group
from .message_session import MessageSesion, MessageSession # noqa from .message_session import MessageSesion, MessageSession # noqa
@@ -59,6 +61,21 @@ class AstrMessageEvent(abc.ABC):
self._result: MessageEventResult | None = None self._result: MessageEventResult | None = None
"""消息事件的结果""" """消息事件的结果"""
self.created_at = time()
"""事件创建时间(Unix timestamp)"""
self.trace = TraceSpan(
name="AstrMessageEvent",
umo=self.unified_msg_origin,
sender_name=self.get_sender_name(),
message_outline=self.get_message_outline(),
)
"""用于记录事件处理的 TraceSpan 对象"""
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._has_send_oper = False
"""在此次事件中是否有过至少一次发送消息的操作""" """在此次事件中是否有过至少一次发送消息的操作"""
self.call_llm = False self.call_llm = False
+73
View File
@@ -0,0 +1,73 @@
import json
import logging
import time
import uuid
from typing import Any
from astrbot import logger
from astrbot.core import LogManager, astrbot_config
from astrbot.core.log import LogQueueHandler
_cached_log_broker = None
_trace_logger = None
def _get_log_broker():
global _cached_log_broker
if _cached_log_broker is not None:
return _cached_log_broker
for handler in logger.handlers:
if isinstance(handler, LogQueueHandler):
_cached_log_broker = handler.log_broker
return _cached_log_broker
return None
def _get_trace_logger():
global _trace_logger
if _trace_logger is not None:
return _trace_logger
# 按配置初始化 trace 文件日志
LogManager.configure_trace_logger(astrbot_config)
_trace_logger = logging.getLogger("astrbot.trace")
return _trace_logger
class TraceSpan:
def __init__(
self,
name: str,
umo: str | None = None,
sender_name: str | None = None,
message_outline: str | None = None,
) -> None:
self.span_id = str(uuid.uuid4())
self.name = name
self.umo = umo
self.sender_name = sender_name
self.message_outline = message_outline
self.started_at = time.time()
def record(self, action: str, **fields: Any) -> None:
payload = {
"type": "trace",
"level": "TRACE",
"time": time.time(),
"span_id": self.span_id,
"name": self.name,
"umo": self.umo,
"sender_name": self.sender_name,
"message_outline": self.message_outline,
"action": action,
"fields": fields,
}
log_broker = _get_log_broker()
if log_broker:
log_broker.publish(payload)
else:
logger.info(f"[trace] {payload}")
trace_logger = _get_trace_logger()
if trace_logger and trace_logger.handlers:
trace_logger.info(json.dumps(payload, ensure_ascii=False))
+6 -132
View File
@@ -94,80 +94,9 @@
:reasoning="msg.content.reasoning" :is-dark="isDark" :reasoning="msg.content.reasoning" :is-dark="isDark"
:initial-expanded="isReasoningExpanded(index)" /> :initial-expanded="isReasoningExpanded(index)" />
<!-- 遍历 message parts (保持顺序) --> <MessagePartsRenderer :parts="msg.content.message" :is-dark="isDark"
<template v-for="(part, partIndex) in msg.content.message" :key="partIndex"> :current-time="currentTime" :downloading-files="downloadingFiles"
<!-- iPython Tool Special Block --> @open-image-preview="openImagePreview" @download-file="downloadFile" />
<template v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.length > 0">
<template v-for="(toolCall, tcIndex) in part.tool_calls" :key="toolCall.id">
<IPythonToolBlock v-if="isIPythonTool(toolCall)" :tool-call="toolCall" style="margin: 8px 0;"
:is-dark="isDark"
:initial-expanded="isIPythonToolExpanded(index, partIndex, tcIndex)" />
</template>
</template>
<!-- Regular Tool Calls Block (for non-iPython tools) -->
<div v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.some(tc => !isIPythonTool(tc))"
class="flex flex-col gap-2">
<div class="font-medium opacity-70" style="font-size: 13px; margin-bottom: 16px;">{{ tm('actions.toolsUsed') }}</div>
<ToolCallCard v-for="(toolCall, tcIndex) in part.tool_calls.filter(tc => !isIPythonTool(tc))"
:key="toolCall.id" :tool-call="toolCall" :is-dark="isDark"
:initial-expanded="isToolCallExpanded(index, partIndex, tcIndex)" />
</div>
<!-- Text (Markdown) -->
<MarkdownRender v-else-if="part.type === 'plain' && part.text && part.text.trim()"
custom-id="message-list"
:custom-html-tags="['ref']"
:content="part.text" :typewriter="false" class="markdown-content"
:is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
<!-- Image -->
<div v-else-if="part.type === 'image' && part.embedded_url" class="embedded-images">
<div class="embedded-image">
<img :src="part.embedded_url" class="bot-embedded-image"
@click="openImagePreview(part.embedded_url)" />
</div>
</div>
<!-- Audio -->
<div v-else-if="part.type === 'record' && part.embedded_url" class="embedded-audio">
<audio controls class="audio-player">
<source :src="part.embedded_url" type="audio/wav">
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
<!-- Files -->
<div v-else-if="part.type === 'file' && part.embedded_file" class="embedded-files">
<div class="embedded-file">
<a v-if="part.embedded_file.url" :href="part.embedded_file.url"
:download="part.embedded_file.filename" class="file-link"
:class="{ 'is-dark': isDark }" :style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ part.embedded_file.filename }}</span>
</a>
<a v-else @click="downloadFile(part.embedded_file)"
class="file-link file-link-download" :class="{ 'is-dark': isDark }"
:style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ part.embedded_file.filename }}</span>
<v-icon v-if="downloadingFiles.has(part.embedded_file.attachment_id)"
size="small" class="download-icon">mdi-loading mdi-spin</v-icon>
<v-icon v-else size="small" class="download-icon">mdi-download</v-icon>
</a>
</div>
</div>
</template>
</template> </template>
</div> </div>
<div class="message-actions" v-if="!msg.content.isLoading || index === messages.length - 1"> <div class="message-actions" v-if="!msg.content.isLoading || index === messages.length - 1">
@@ -250,14 +179,13 @@
<script> <script>
import { useI18n, useModuleI18n } from '@/i18n/composables'; import { useI18n, useModuleI18n } from '@/i18n/composables';
import { MarkdownRender, enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue' import { enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
import 'markstream-vue/index.css' import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css' import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css'; import 'highlight.js/styles/github.css';
import axios from 'axios'; import axios from 'axios';
import ReasoningBlock from './message_list_comps/ReasoningBlock.vue'; import ReasoningBlock from './message_list_comps/ReasoningBlock.vue';
import IPythonToolBlock from './message_list_comps/IPythonToolBlock.vue'; import MessagePartsRenderer from './message_list_comps/MessagePartsRenderer.vue';
import ToolCallCard from './message_list_comps/ToolCallCard.vue';
import RefNode from './message_list_comps/RefNode.vue'; import RefNode from './message_list_comps/RefNode.vue';
import ActionRef from './message_list_comps/ActionRef.vue'; import ActionRef from './message_list_comps/ActionRef.vue';
@@ -270,10 +198,8 @@ setCustomComponents('message-list', { ref: RefNode });
export default { export default {
name: 'MessageList', name: 'MessageList',
components: { components: {
MarkdownRender,
ReasoningBlock, ReasoningBlock,
IPythonToolBlock, MessagePartsRenderer,
ToolCallCard,
RefNode, RefNode,
ActionRef ActionRef
}, },
@@ -319,8 +245,6 @@ export default {
scrollTimer: null, scrollTimer: null,
expandedReasoning: new Set(), // Track which reasoning blocks are expanded expandedReasoning: new Set(), // Track which reasoning blocks are expanded
downloadingFiles: new Set(), // Track which files are being downloaded downloadingFiles: new Set(), // Track which files are being downloaded
expandedToolCalls: new Set(), // Track which tool call cards are expanded
expandedIPythonTools: new Set(), // Track which iPython tools are expanded
elapsedTimeTimer: null, // Timer for updating elapsed time elapsedTimeTimer: null, // Timer for updating elapsed time
currentTime: Date.now() / 1000, // Current time for elapsed time calculation currentTime: Date.now() / 1000, // Current time for elapsed time calculation
// //
@@ -541,23 +465,6 @@ export default {
return this.expandedReasoning.has(messageIndex); return this.expandedReasoning.has(messageIndex);
}, },
// Toggle iPython tool expansion state
toggleIPythonTool(messageIndex, partIndex, toolCallIndex) {
const key = `${messageIndex}-${partIndex}-${toolCallIndex}`;
if (this.expandedIPythonTools.has(key)) {
this.expandedIPythonTools.delete(key);
} else {
this.expandedIPythonTools.add(key);
}
// Force reactivity
this.expandedIPythonTools = new Set(this.expandedIPythonTools);
},
// Check if iPython tool is expanded
isIPythonToolExpanded(messageIndex, partIndex, toolCallIndex) {
return this.expandedIPythonTools.has(`${messageIndex}-${partIndex}-${toolCallIndex}`);
},
// //
async downloadFile(file) { async downloadFile(file) {
if (!file.attachment_id) return; if (!file.attachment_id) return;
@@ -821,22 +728,6 @@ export default {
} }
}, },
// Tool call related methods
toggleToolCall(messageIndex, partIndex, toolCallIndex) {
const key = `${messageIndex}-${partIndex}-${toolCallIndex}`;
if (this.expandedToolCalls.has(key)) {
this.expandedToolCalls.delete(key);
} else {
this.expandedToolCalls.add(key);
}
// Force reactivity
this.expandedToolCalls = new Set(this.expandedToolCalls);
},
isToolCallExpanded(messageIndex, partIndex, toolCallIndex) {
return this.expandedToolCalls.has(`${messageIndex}-${partIndex}-${toolCallIndex}`);
},
// Start timer for updating elapsed time // Start timer for updating elapsed time
startElapsedTimeTimer() { startElapsedTimeTimer() {
// Update every 12ms for sub-second precision, then every second after 1s // Update every 12ms for sub-second precision, then every second after 1s
@@ -898,18 +789,6 @@ export default {
} }
}, },
// Format tool result for display
formatToolResult(result) {
if (!result) return '';
// Try to parse as JSON for pretty formatting
try {
const parsed = JSON.parse(result);
return JSON.stringify(parsed, null, 2);
} catch {
return result;
}
},
// Get input tokens (input_other + input_cached) // Get input tokens (input_other + input_cached)
getInputTokens(tokenUsage) { getInputTokens(tokenUsage) {
if (!tokenUsage) return 0; if (!tokenUsage) return 0;
@@ -943,11 +822,6 @@ export default {
}, 300); }, 300);
}, },
// Check if tool is iPython executor
isIPythonTool(toolCall) {
return toolCall.name === 'astrbot_execute_ipython' || toolCall.name === 'astrbot_execute_python';
},
// Open refs sidebar // Open refs sidebar
openRefsSidebar(refs) { openRefsSidebar(refs) {
this.$emit('openRefs', refs); this.$emit('openRefs', refs);
@@ -1,14 +1,6 @@
<template> <template>
<div class="mb-3 mt-1.5"> <div class="ipython-tool-block" :class="{ compact: !showHeader }">
<div class="ipython-header" :class="{ 'expanded': isExpanded }" @click="toggleExpanded"> <div v-if="displayExpanded" class="py-3 animate-fade-in">
<span class="ipython-label">
{{ tm('actions.pythonCodeAnalysis') }}
</span>
<v-icon size="small" class="ipython-icon" :class="{ 'rotated': isExpanded }">
mdi-chevron-right
</v-icon>
</div>
<div v-if="isExpanded" class="py-3 animate-fade-in">
<!-- Code Section --> <!-- Code Section -->
<div class="code-section"> <div class="code-section">
<div v-if="shikiReady && code" class="code-highlighted" <div v-if="shikiReady && code" class="code-highlighted"
@@ -46,6 +38,14 @@ const props = defineProps({
initialExpanded: { initialExpanded: {
type: Boolean, type: Boolean,
default: false default: false
},
showHeader: {
type: Boolean,
default: true
},
forceExpanded: {
type: Boolean,
default: null
} }
}); });
@@ -92,9 +92,12 @@ const highlightedCode = computed(() => {
} }
}); });
const toggleExpanded = () => { const displayExpanded = computed(() => {
isExpanded.value = !isExpanded.value; if (props.forceExpanded === null) {
}; return isExpanded.value;
}
return props.forceExpanded;
});
onMounted(async () => { onMounted(async () => {
try { try {
@@ -110,40 +113,13 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.mb-3 { .ipython-tool-block {
margin-bottom: 12px; margin-bottom: 12px;
}
.mt-1\.5 {
margin-top: 6px; margin-top: 6px;
} }
.ipython-header { .ipython-tool-block.compact {
display: inline-flex; margin: 0;
align-items: center;
cursor: pointer;
user-select: none;
border-radius: 20px;
opacity: 0.7;
transition: opacity;
}
.ipython-header:hover,
.ipython-header.expanded {
opacity: 1;
}
.ipython-label {
font-size: 16px;
}
.ipython-icon {
margin-left: 6px;
transition: transform 0.2s ease;
}
.ipython-icon.rotated {
transform: rotate(90deg);
} }
.py-3 { .py-3 {
@@ -160,6 +136,7 @@ onMounted(async () => {
overflow: hidden; overflow: hidden;
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
overflow-x: auto;
} }
.code-fallback { .code-fallback {
@@ -208,6 +185,10 @@ onMounted(async () => {
animation: fadeIn 0.2s ease-in-out; animation: fadeIn 0.2s ease-in-out;
} }
:deep(.code-highlighted pre) {
background-color: transparent !important;
}
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
@@ -0,0 +1,334 @@
<template>
<template v-for="(renderPart, renderIndex) in getRenderParts(parts)" :key="renderPart.key">
<!-- Grouped Tool Calls (consecutive tool_call parts) -->
<div v-if="renderPart.type === 'tool_group'" class="tool-call-compact">
<transition-group name="tool-call-item" tag="div" class="tool-call-items">
<ToolCallItem v-for="(toolCall, tcIndex) in renderPart.toolCalls" :key="toolCall.id" :is-dark="isDark">
<template #label="{ expanded }">
<v-icon size="x-small" v-if="toolCall.name.includes('web_search') || toolCall.name.includes('tavily')">
mdi-web
</v-icon>
<v-icon size="x-small" v-else-if="toolCall.name === 'astrbot_execute_shell'">
mdi-console-line
</v-icon>
<v-icon size="x-small" v-else>
mdi-wrench
</v-icon>
{{ tm('actions.toolCallUsed', { name: toolCall.name }) }}
<span style="opacity: 0.6;">{{ toolCall.finished_ts ? formatDuration(toolCall.finished_ts -
toolCall.ts) : getElapsedTime(toolCall.ts) }}</span>
<v-icon size="x-small" class="tool-call-chevron" :class="{ rotated: expanded }">
mdi-chevron-right
</v-icon>
</template>
<template #details>
<div class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value">{{ toolCall.id }}</code>
</div>
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json">{{ formatToolArgs(toolCall.args) }}</pre>
</div>
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre
class="detail-value detail-json detail-result">{{ formatToolResult(toolCall.result) }}</pre>
</div>
</template>
</ToolCallItem>
</transition-group>
</div>
<!-- iPython Tool Block -->
<ToolCallItem v-else-if="renderPart.type === 'ipython'" :is-dark="isDark" style="margin: 8px 0 4px;">
<template #label="{ expanded }">
<v-icon size="x-small">
mdi-code-json
</v-icon>
<span class="ipython-label">{{ tm('actions.pythonCodeAnalysis') }}</span>
<span style="opacity: 0.6;">{{ renderPart.toolCall.finished_ts ?
formatDuration(renderPart.toolCall.finished_ts -
renderPart.toolCall.ts) : getElapsedTime(renderPart.toolCall.ts) }}</span>
<v-icon size="small" class="ipython-icon" :class="{ rotated: expanded }">
mdi-chevron-right
</v-icon>
</template>
<template #details>
<IPythonToolBlock :tool-call="renderPart.toolCall" :is-dark="isDark" :show-header="false"
:force-expanded="true" />
</template>
</ToolCallItem>
<!-- Text (Markdown) -->
<MarkdownRender
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
custom-id="message-list" :custom-html-tags="['ref']" :content="renderPart.part.text" :typewriter="false"
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
<!-- Image -->
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
<div class="embedded-image">
<img :src="renderPart.part.embedded_url" class="bot-embedded-image"
@click="emitOpenImage(renderPart.part.embedded_url)" />
</div>
</div>
<!-- Audio -->
<div v-else-if="renderPart.part.type === 'record' && renderPart.part.embedded_url" class="embedded-audio">
<audio controls class="audio-player">
<source :src="renderPart.part.embedded_url" type="audio/wav">
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
<!-- Files -->
<div v-else-if="renderPart.part.type === 'file' && renderPart.part.embedded_file" class="embedded-files">
<div class="embedded-file">
<a v-if="renderPart.part.embedded_file.url" :href="renderPart.part.embedded_file.url"
:download="renderPart.part.embedded_file.filename" class="file-link" :class="{ 'is-dark': isDark }"
:style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ renderPart.part.embedded_file.filename }}</span>
</a>
<a v-else @click="emitDownloadFile(renderPart.part.embedded_file)" class="file-link file-link-download"
:class="{ 'is-dark': isDark }" :style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ renderPart.part.embedded_file.filename }}</span>
<v-icon v-if="downloadingFiles?.has(renderPart.part.embedded_file.attachment_id)" size="small"
class="download-icon">mdi-loading mdi-spin</v-icon>
<v-icon v-else size="small" class="download-icon">mdi-download</v-icon>
</a>
</div>
</div>
</template>
</template>
<script setup>
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { MarkdownRender } from 'markstream-vue';
import IPythonToolBlock from './IPythonToolBlock.vue';
import ToolCallItem from './ToolCallItem.vue';
const props = defineProps({
parts: {
type: Array,
required: true
},
isDark: {
type: Boolean,
default: false
},
currentTime: {
type: Number,
default: 0
},
downloadingFiles: {
type: Object,
default: () => new Set()
}
});
const emit = defineEmits(['open-image-preview', 'download-file']);
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const emitOpenImage = (url) => {
emit('open-image-preview', url);
};
const emitDownloadFile = (file) => {
emit('download-file', file);
};
const formatDuration = (seconds) => {
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`;
}
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
}
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
};
const getElapsedTime = (startTs) => {
const elapsed = props.currentTime - startTs;
return formatDuration(elapsed);
};
const formatToolResult = (result) => {
if (!result) return '';
if (typeof result === 'string') {
try {
const parsed = JSON.parse(result);
return JSON.stringify(parsed, null, 2);
} catch {
return result;
}
}
return JSON.stringify(result, null, 2);
};
const formatToolArgs = (args) => {
if (!args) return '';
if (typeof args === 'string') {
try {
const parsed = JSON.parse(args);
return JSON.stringify(parsed, null, 2);
} catch {
return args;
}
}
return JSON.stringify(args, null, 2);
};
const isIPythonTool = (toolCall) => {
return toolCall.name === 'astrbot_execute_ipython' || toolCall.name === 'astrbot_execute_python';
};
const getRenderParts = (messageParts) => {
if (!Array.isArray(messageParts)) return [];
const rendered = [];
let pendingToolCalls = [];
let groupIndex = 0;
const flushPending = (endIndex) => {
if (!pendingToolCalls.length) return;
rendered.push({
type: 'tool_group',
toolCalls: pendingToolCalls,
key: `tool-group-${groupIndex}-${endIndex}`
});
pendingToolCalls = [];
groupIndex += 1;
};
messageParts.forEach((part, idx) => {
if (part?.type === 'tool_call' && Array.isArray(part.tool_calls) && part.tool_calls.length) {
part.tool_calls.forEach((toolCall, tcIndex) => {
if (isIPythonTool(toolCall)) {
flushPending(idx - 1);
rendered.push({
type: 'ipython',
toolCall,
key: `ipython-${idx}-${tcIndex}`
});
return;
}
pendingToolCalls.push(toolCall);
});
return;
}
flushPending(idx - 1);
rendered.push({
type: 'part',
part,
key: `part-${idx}`
});
});
flushPending(messageParts.length - 1);
return rendered;
};
</script>
<style scoped>
.tool-call-compact {
display: flex;
flex-direction: column;
gap: 8px;
margin: 8px 0 4px;
}
.tool-call-group-title {
font-size: 13px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
}
.tool-call-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.tool-call-detail-row {
display: flex;
flex-direction: column;
margin-bottom: 6px;
}
.tool-call-detail-row:last-child {
margin-bottom: 0;
}
.detail-label {
font-size: 11px;
font-weight: 600;
color: var(--v-theme-secondaryText);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.detail-value {
font-size: 12px;
color: var(--v-theme-primaryText);
background-color: transparent;
padding: 2px 6px;
border-radius: 4px;
word-break: break-word;
}
.detail-json {
font-family: 'Fira Code', 'Consolas', monospace;
white-space: pre-wrap;
max-height: 220px;
overflow-y: auto;
margin: 0;
}
.detail-result {
max-height: 320px;
background-color: transparent;
}
.tool-call-item-enter-active,
.tool-call-item-leave-active {
transition: all 0.2s ease;
}
.tool-call-item-enter-from,
.tool-call-item-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.ipython-icon,
.tool-call-chevron {
margin-left: 6px;
transition: transform 0.2s ease;
}
.ipython-icon.rotated {
transform: rotate(90deg);
}
.tool-call-chevron.rotated {
transform: rotate(90deg);
}
</style>
@@ -0,0 +1,74 @@
<template>
<div class="tool-call-item">
<div class="tool-call-line" role="button" tabindex="0"
@click="toggleExpanded"
@keydown.enter="toggleExpanded"
@keydown.space.prevent="toggleExpanded">
<slot name="label" :expanded="isExpanded" />
</div>
<transition name="tool-call-fade">
<div v-if="isExpanded" class="tool-call-inline-details" :class="{ 'is-dark': isDark }">
<slot name="details" />
</div>
</transition>
</div>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
isDark: {
type: Boolean,
default: false
}
});
const isExpanded = ref(false);
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
</script>
<style scoped>
.tool-call-line {
font-size: 14px;
color: var(--v-theme-secondaryText);
opacity: 0.85;
cursor: pointer;
user-select: none;
transition: color 0.2s ease, opacity 0.2s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.tool-call-line:hover {
color: var(--v-theme-secondary);
opacity: 1;
}
.tool-call-inline-details {
margin-top: 6px;
padding: 8px 10px;
border-left: 2px solid var(--v-theme-border);
border-radius: 6px;
background-color: rgba(0, 0, 0, 0.02);
}
.tool-call-inline-details.is-dark {
background-color: rgba(255, 255, 255, 0.04);
border-left-color: rgba(255, 255, 255, 0.15);
}
.tool-call-fade-enter-active,
.tool-call-fade-leave-active {
transition: opacity 0.1s ease;
}
.tool-call-fade-enter-from,
.tool-call-fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,487 @@
<script setup>
import axios from 'axios';
import { EventSourcePolyfill } from 'event-source-polyfill';
</script>
<template>
<div class="trace-wrapper">
<div class="trace-table" ref="scrollEl" :style="{ height: tableHeight }">
<div class="trace-row trace-header">
<div class="trace-cell time">Time</div>
<div class="trace-cell span">Event ID</div>
<div class="trace-cell umo">UMO</div>
<!-- <div class="trace-cell count">Records</div> -->
<!-- <div class="trace-cell last">Last</div> -->
<div class="trace-cell sender">Sender</div>
<div class="trace-cell outline">Outline</div>
<div class="trace-cell fields"></div>
</div>
<div class="trace-group" :class="{ highlight: highlightMap[event.span_id] }" v-for="event in events"
:key="event.span_id">
<div class="trace-row trace-event">
<div class="trace-cell time">{{ formatTime(event.first_time) }}</div>
<div class="trace-cell span" :title="event.span_id">
<div class="event-title">
{{ shortSpan(event.span_id) }}
</div>
</div>
<div class="trace-cell umo">{{ event.umo }}</div>
<!-- <div class="trace-cell count">
<div class="event-meta">{{ event.records.length }}</div>
</div> -->
<!-- <div class="trace-cell last">
<div class="event-meta">{{ formatTime(event.last_time) }}</div>
</div> -->
<div class="trace-cell sender">
<div class="event-sub" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{{
event.sender_name || '-' }}</div>
</div>
<div class="trace-cell outline">
<div class="event-sub outline">{{ event.message_outline || '-' }}</div>
</div>
<div class="trace-cell fields event-controls">
<v-btn size="x-small" variant="text" color="primary" @click="toggleEvent(event.span_id)">
{{ event.collapsed ? 'Expand' : 'Collapse' }}
<span v-if="event.hasAgentPrepare" class="agent-dot" />
</v-btn>
</div>
</div>
<div class="trace-records" v-if="!event.collapsed">
<div class="trace-record" v-for="record in getVisibleRecords(event)" :key="record.key">
<div class="trace-record-time">{{ record.timeLabel }}</div>
<div class="trace-record-action">{{ record.action }}</div>
<pre class="trace-record-fields">{{ record.fieldsText }}</pre>
</div>
<div class="event-more" v-if="event.visibleCount < event.records.length">
<v-btn size="x-small" variant="tonal" color="primary" @click="showMore(event.span_id)">
Show more
</v-btn>
</div>
</div>
</div>
<div v-if="events.length === 0" class="trace-empty">No trace data yet.</div>
</div>
</div>
</template>
<script>
export default {
name: 'TraceDisplayer',
props: {
autoScroll: {
type: Boolean,
default: true
},
maxItems: {
type: Number,
default: 300
}
},
data() {
return {
events: [],
eventIndex: {},
highlightMap: {},
highlightTimers: {},
eventSource: null,
retryTimer: null,
retryAttempts: 0,
maxRetryAttempts: 10,
baseRetryDelay: 1000,
lastEventId: null,
tableHeight: 'auto'
};
},
async mounted() {
await this.fetchTraceHistory();
this.connectSSE();
this.updateTableHeight();
window.addEventListener('resize', this.updateTableHeight);
},
beforeUnmount() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.retryAttempts = 0;
window.removeEventListener('resize', this.updateTableHeight);
},
methods: {
updateTableHeight() {
this.$nextTick(() => {
const el = this.$refs.scrollEl;
if (!el || typeof window === 'undefined') return;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const offsetTop = el.getBoundingClientRect().top;
const height = Math.max(viewportHeight - offsetTop, 0);
this.tableHeight = `${height}px`;
});
},
async fetchTraceHistory() {
try {
const res = await axios.get('/api/log-history');
const logs = res.data?.data?.logs || [];
const traces = logs.filter((item) => item.type === 'trace');
this.processNewTraces(traces);
} catch (err) {
console.error('Failed to fetch trace history:', err);
}
},
connectSSE() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
const token = localStorage.getItem('token');
this.eventSource = new EventSourcePolyfill('/api/live-log', {
headers: {
Authorization: token ? `Bearer ${token}` : ''
},
heartbeatTimeout: 300000,
withCredentials: true
});
this.eventSource.onopen = () => {
this.retryAttempts = 0;
if (!this.lastEventId) {
this.fetchTraceHistory();
}
};
this.eventSource.onmessage = (event) => {
try {
if (event.lastEventId) {
this.lastEventId = event.lastEventId;
}
const payload = JSON.parse(event.data);
if (payload?.type !== 'trace') {
return;
}
this.processNewTraces([payload]);
} catch (e) {
console.error('Failed to parse trace payload:', e);
}
};
this.eventSource.onerror = (err) => {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
if (this.retryAttempts >= this.maxRetryAttempts) {
console.error('Trace stream reached max retry attempts.');
return;
}
const delay = Math.min(
this.baseRetryDelay * Math.pow(2, this.retryAttempts),
30000
);
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.retryTimer = setTimeout(async () => {
this.retryAttempts++;
if (!this.lastEventId) {
await this.fetchTraceHistory();
}
this.connectSSE();
}, delay);
};
},
processNewTraces(newTraces) {
if (!newTraces || newTraces.length === 0) return;
let hasUpdate = false;
const touched = new Set();
newTraces.forEach((trace) => {
if (!trace.span_id) return;
const recordKey = `${trace.time}-${trace.span_id}-${trace.action}`;
let event = this.eventIndex[trace.span_id];
if (!event) {
event = {
span_id: trace.span_id,
name: trace.name,
umo: trace.umo,
sender_name: trace.sender_name,
message_outline: trace.message_outline,
first_time: trace.time,
last_time: trace.time,
collapsed: true,
visibleCount: 20,
records: [],
hasAgentPrepare: trace.action === 'astr_agent_prepare'
};
this.eventIndex[trace.span_id] = event;
this.events.push(event);
hasUpdate = true;
}
const exists = event.records.some((item) => item.key === recordKey);
if (exists) return;
event.records.push({
time: trace.time,
action: trace.action,
fieldsText: this.formatFields(trace.fields),
timeLabel: this.formatTime(trace.time),
key: recordKey
});
if (trace.action === 'astr_agent_prepare') {
event.hasAgentPrepare = true;
}
if (!event.first_time || trace.time < event.first_time) {
event.first_time = trace.time;
}
if (!event.last_time || trace.time > event.last_time) {
event.last_time = trace.time;
}
if (!event.sender_name && trace.sender_name) {
event.sender_name = trace.sender_name;
}
if (!event.message_outline && trace.message_outline) {
event.message_outline = trace.message_outline;
}
touched.add(trace.span_id);
hasUpdate = true;
});
if (hasUpdate) {
this.events.forEach((event) => {
event.records.sort((a, b) => b.time - a.time);
});
this.events.sort((a, b) => b.first_time - a.first_time);
if (this.events.length > this.maxItems) {
const overflow = this.events.length - this.maxItems;
const removed = this.events.splice(this.maxItems, overflow);
removed.forEach((event) => {
delete this.eventIndex[event.span_id];
});
}
touched.forEach((spanId) => {
this.pulseEvent(spanId);
});
}
},
scrollToBottom() {
const el = this.$refs.scrollEl;
if (!el) return;
el.scrollTop = el.scrollHeight;
},
toggleEvent(spanId) {
const event = this.eventIndex[spanId];
if (!event) return;
event.collapsed = !event.collapsed;
},
showMore(spanId) {
const event = this.eventIndex[spanId];
if (!event) return;
event.visibleCount = Math.min(event.records.length, event.visibleCount + 20);
},
pulseEvent(spanId) {
if (!spanId) return;
if (this.highlightTimers[spanId]) {
clearTimeout(this.highlightTimers[spanId]);
}
this.highlightMap = { ...this.highlightMap, [spanId]: true };
const remove = setTimeout(() => {
const next = { ...this.highlightMap };
delete next[spanId];
this.highlightMap = next;
const timers = { ...this.highlightTimers };
delete timers[spanId];
this.highlightTimers = timers;
}, 1200);
this.highlightTimers = { ...this.highlightTimers, [spanId]: remove };
},
getVisibleRecords(event) {
if (!event.records.length) return [];
return event.records.slice(0, event.visibleCount);
},
formatTime(ts) {
if (!ts) return '';
const date = new Date(ts * 1000);
const base = date.toLocaleString();
const ms = String(date.getMilliseconds()).padStart(3, '0');
return `${base}.${ms}`;
},
shortSpan(spanId) {
if (!spanId) return '';
return spanId.slice(0, 8);
},
formatFields(fields) {
if (!fields) return '';
try {
const text = JSON.stringify(fields, null, 2);
if (text.length > 2000) {
return `${text}`;
}
return text;
} catch (e) {
return String(fields);
}
}
}
};
</script>
<style scoped>
.trace-wrapper {
height: 100%;
}
.trace-table {
background: transparent;
border-radius: 0;
padding: 0;
height: 100%;
overflow-y: auto;
color: #2b3340;
font-family: 'Fira Code', monospace;
}
.trace-row {
display: grid;
grid-template-columns: 200px 100px 300px 90px 180px 140px 200px 1fr;
gap: 12px;
}
.trace-group {
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
background: transparent;
padding: 8px 0;
}
.trace-group.highlight {
background: rgba(59, 130, 246, 0.08);
transition: background 0.6s ease;
}
.trace-event {
align-items: start;
}
.trace-header {
font-weight: 600;
color: #6b7280;
border-bottom: 1px solid rgba(15, 23, 42, 0.12);
padding-bottom: 10px;
}
.trace-cell {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
}
.event-title {
font-weight: 600;
color: #1f2937;
}
.event-meta {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
.event-sub {
font-size: 12px;
color: #4b5563;
margin-top: 2px;
word-break: break-word;
}
.event-sub.outline {
color: #6b7280;
}
.event-controls {
display: flex;
justify-content: flex-end;
}
.agent-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
margin-left: 6px;
vertical-align: middle;
}
.trace-cell.fields pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #4b5563;
}
.trace-empty {
padding: 24px;
text-align: center;
color: #6b7280;
}
@media (max-width: 1200px) {
.trace-row {
grid-template-columns: 140px 160px 300px 70px 140px 180px 1fr;
}
.trace-cell.fields {
grid-column: 1 / -1;
}
}
.trace-record {
display: grid;
grid-template-columns: 200px 120px 1fr;
gap: 8px;
padding: 2px 0;
}
.trace-record:last-child {
border-bottom: none;
}
.trace-record-time {
color: #6b7280;
font-size: 11px;
}
.trace-record-action {
color: #1f2937;
font-weight: 600;
font-size: 11px;
}
.trace-record-fields {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #4b5563;
font-size: 10px;
}
.event-more {
display: flex;
justify-content: center;
padding: 6px 0 2px;
}
.trace-records {
padding: 4px 0 2px 0;
}
</style>
+2 -1
View File
@@ -46,6 +46,7 @@ export class I18nLoader {
{ name: 'features/config', path: 'features/config.json' }, { name: 'features/config', path: 'features/config.json' },
{ name: 'features/config-metadata', path: 'features/config-metadata.json' }, { name: 'features/config-metadata', path: 'features/config-metadata.json' },
{ name: 'features/console', path: 'features/console.json' }, { name: 'features/console', path: 'features/console.json' },
{ name: 'features/trace', path: 'features/trace.json' },
{ name: 'features/about', path: 'features/about.json' }, { name: 'features/about', path: 'features/about.json' },
{ name: 'features/settings', path: 'features/settings.json' }, { name: 'features/settings', path: 'features/settings.json' },
{ name: 'features/auth', path: 'features/auth.json' }, { name: 'features/auth', path: 'features/auth.json' },
@@ -295,4 +296,4 @@ export class I18nLoader {
} }
} }
@@ -11,6 +11,7 @@
"conversation": "Conversations", "conversation": "Conversations",
"sessionManagement": "Custom Rules", "sessionManagement": "Custom Rules",
"console": "Console", "console": "Console",
"trace": "Trace",
"alkaid": "Alkaid Lab", "alkaid": "Alkaid Lab",
"knowledgeBase": "Knowledge Base", "knowledgeBase": "Knowledge Base",
"about": "About", "about": "About",
@@ -49,6 +49,7 @@
"reply": "Reply", "reply": "Reply",
"providerConfig": "AI Configuration", "providerConfig": "AI Configuration",
"toolsUsed": "Tool Used", "toolsUsed": "Tool Used",
"toolCallUsed": "Used {name} tool",
"pythonCodeAnalysis": "Python Code Analysis Used" "pythonCodeAnalysis": "Python Code Analysis Used"
}, },
"ipython": { "ipython": {
@@ -133,4 +134,4 @@
"sendMessageFailed": "Failed to send message, please try again", "sendMessageFailed": "Failed to send message, please try again",
"createSessionFailed": "Failed to create session, please refresh the page" "createSessionFailed": "Failed to create session, please refresh the page"
} }
} }
@@ -564,6 +564,30 @@
"description": "Console Log Level", "description": "Console Log Level",
"hint": "Log level for console output." "hint": "Log level for console output."
}, },
"log_file_enable": {
"description": "Enable File Logging",
"hint": "Write logs to a file in addition to the console."
},
"log_file_path": {
"description": "Log File Path",
"hint": "Relative paths are resolved under the data directory, e.g. logs/astrbot.log; absolute paths are supported."
},
"log_file_max_mb": {
"description": "Log File Max Size (MB)",
"hint": "Rotate when exceeding this size; default 20MB."
},
"trace_log_enable": {
"description": "Enable Trace File Logging",
"hint": "Write trace events to a separate file (does not change console output)."
},
"trace_log_path": {
"description": "Trace Log File Path",
"hint": "Relative paths are resolved under the data directory, e.g. logs/astrbot.trace.log; absolute paths are supported."
},
"trace_log_max_mb": {
"description": "Trace Log Max Size (MB)",
"hint": "Rotate when exceeding this size; default 20MB."
},
"pip_install_arg": { "pip_install_arg": {
"description": "Additional pip Installation Arguments", "description": "Additional pip Installation Arguments",
"hint": "When installing plugin dependencies, Python's pip tool will be used. Additional arguments can be provided here, such as `--break-system-package`." "hint": "When installing plugin dependencies, Python's pip tool will be used. Additional arguments can be provided here, such as `--break-system-package`."
@@ -0,0 +1,7 @@
{
"title": "Trace",
"autoScroll": {
"enabled": "Auto-scroll: On",
"disabled": "Auto-scroll: Off"
}
}
@@ -11,6 +11,7 @@
"conversation": "对话数据", "conversation": "对话数据",
"sessionManagement": "自定义规则", "sessionManagement": "自定义规则",
"console": "平台日志", "console": "平台日志",
"trace": "追踪",
"alkaid": "Alkaid", "alkaid": "Alkaid",
"knowledgeBase": "知识库", "knowledgeBase": "知识库",
"about": "关于", "about": "关于",
@@ -30,4 +31,4 @@
"selectVersion": "选择版本", "selectVersion": "选择版本",
"current": "当前" "current": "当前"
} }
} }
@@ -49,6 +49,7 @@
"reply": "引用回复", "reply": "引用回复",
"providerConfig": "AI 配置", "providerConfig": "AI 配置",
"toolsUsed": "已使用工具", "toolsUsed": "已使用工具",
"toolCallUsed": "已使用 {name} 工具",
"pythonCodeAnalysis": "已使用 Python 代码分析" "pythonCodeAnalysis": "已使用 Python 代码分析"
}, },
"ipython": { "ipython": {
@@ -135,4 +136,4 @@
"sendMessageFailed": "发送消息失败,请重试", "sendMessageFailed": "发送消息失败,请重试",
"createSessionFailed": "创建会话失败,请刷新页面重试" "createSessionFailed": "创建会话失败,请刷新页面重试"
} }
} }
@@ -562,6 +562,30 @@
"description": "控制台日志级别", "description": "控制台日志级别",
"hint": "控制台输出日志的级别。" "hint": "控制台输出日志的级别。"
}, },
"log_file_enable": {
"description": "启用文件日志",
"hint": "在控制台输出的同时,将日志写入文件。"
},
"log_file_path": {
"description": "日志文件路径",
"hint": "相对路径以 data 目录为基准,例如 logs/astrbot.log;支持绝对路径。"
},
"log_file_max_mb": {
"description": "日志文件大小上限 (MB)",
"hint": "超过大小后自动轮转,默认 20MB。"
},
"trace_log_enable": {
"description": "启用 Trace 文件日志",
"hint": "将 Trace 事件写入独立文件(不影响控制台输出)。"
},
"trace_log_path": {
"description": "Trace 日志文件路径",
"hint": "相对路径以 data 目录为基准,例如 logs/astrbot.trace.log;支持绝对路径。"
},
"trace_log_max_mb": {
"description": "Trace 日志大小上限 (MB)",
"hint": "超过大小后自动轮转,默认 20MB。"
},
"pip_install_arg": { "pip_install_arg": {
"description": "pip 安装额外参数", "description": "pip 安装额外参数",
"hint": "安装插件依赖时,会使用 Python 的 pip 工具。这里可以填写额外的参数,如 `--break-system-package` 等。" "hint": "安装插件依赖时,会使用 Python 的 pip 工具。这里可以填写额外的参数,如 `--break-system-package` 等。"
@@ -0,0 +1,7 @@
{
"title": "追踪",
"autoScroll": {
"enabled": "自动滚动:开",
"disabled": "自动滚动:关"
}
}
+5 -1
View File
@@ -19,6 +19,7 @@ import zhCNPlatform from './locales/zh-CN/features/platform.json';
import zhCNConfig from './locales/zh-CN/features/config.json'; import zhCNConfig from './locales/zh-CN/features/config.json';
import zhCNConfigMetadata from './locales/zh-CN/features/config-metadata.json'; import zhCNConfigMetadata from './locales/zh-CN/features/config-metadata.json';
import zhCNConsole from './locales/zh-CN/features/console.json'; import zhCNConsole from './locales/zh-CN/features/console.json';
import zhCNTrace from './locales/zh-CN/features/trace.json';
import zhCNAbout from './locales/zh-CN/features/about.json'; import zhCNAbout from './locales/zh-CN/features/about.json';
import zhCNSettings from './locales/zh-CN/features/settings.json'; import zhCNSettings from './locales/zh-CN/features/settings.json';
import zhCNAuth from './locales/zh-CN/features/auth.json'; import zhCNAuth from './locales/zh-CN/features/auth.json';
@@ -56,6 +57,7 @@ import enUSPlatform from './locales/en-US/features/platform.json';
import enUSConfig from './locales/en-US/features/config.json'; import enUSConfig from './locales/en-US/features/config.json';
import enUSConfigMetadata from './locales/en-US/features/config-metadata.json'; import enUSConfigMetadata from './locales/en-US/features/config-metadata.json';
import enUSConsole from './locales/en-US/features/console.json'; import enUSConsole from './locales/en-US/features/console.json';
import enUSTrace from './locales/en-US/features/trace.json';
import enUSAbout from './locales/en-US/features/about.json'; import enUSAbout from './locales/en-US/features/about.json';
import enUSSettings from './locales/en-US/features/settings.json'; import enUSSettings from './locales/en-US/features/settings.json';
import enUSAuth from './locales/en-US/features/auth.json'; import enUSAuth from './locales/en-US/features/auth.json';
@@ -97,6 +99,7 @@ export const translations = {
config: zhCNConfig, config: zhCNConfig,
'config-metadata': zhCNConfigMetadata, 'config-metadata': zhCNConfigMetadata,
console: zhCNConsole, console: zhCNConsole,
trace: zhCNTrace,
about: zhCNAbout, about: zhCNAbout,
settings: zhCNSettings, settings: zhCNSettings,
auth: zhCNAuth, auth: zhCNAuth,
@@ -142,6 +145,7 @@ export const translations = {
config: enUSConfig, config: enUSConfig,
'config-metadata': enUSConfigMetadata, 'config-metadata': enUSConfigMetadata,
console: enUSConsole, console: enUSConsole,
trace: enUSTrace,
about: enUSAbout, about: enUSAbout,
settings: enUSSettings, settings: enUSSettings,
auth: enUSAuth, auth: enUSAuth,
@@ -169,4 +173,4 @@ export const translations = {
} }
}; };
export type TranslationData = typeof translations; export type TranslationData = typeof translations;
@@ -72,6 +72,11 @@ const sidebarItem: menu[] = [
icon: 'mdi-console', icon: 'mdi-console',
to: '/console' to: '/console'
}, },
{
title: 'core.navigation.trace',
icon: 'mdi-timeline-text-outline',
to: '/trace'
},
] ]
} }
// { // {
+5
View File
@@ -61,6 +61,11 @@ const MainRoutes = {
path: '/console', path: '/console',
component: () => import('@/views/ConsolePage.vue') component: () => import('@/views/ConsolePage.vue')
}, },
{
name: 'Trace',
path: '/trace',
component: () => import('@/views/TracePage.vue')
},
{ {
name: 'NativeKnowledgeBase', name: 'NativeKnowledgeBase',
path: '/knowledge-base', path: '/knowledge-base',
+21
View File
@@ -0,0 +1,21 @@
<script setup>
import TraceDisplayer from '@/components/shared/TraceDisplayer.vue';
import { useModuleI18n } from '@/i18n/composables';
const { tm } = useModuleI18n('features/trace');
</script>
<template>
<div style="height: 100%;">
<TraceDisplayer />
</div>
</template>
<script>
export default {
name: 'TracePage',
components: {
TraceDisplayer
}
};
</script>