Compare commits
9 Commits
v4.13.0
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
| eafb339281 | |||
| f03dd87502 | |||
| 6e475074a4 | |||
| 2aa0986295 | |||
| 34c6ceb67c | |||
| 906877cbe6 | |||
| 609180022e | |||
| 49c087a141 | |||
| 70f12cd686 |
@@ -41,7 +41,7 @@ AstrBot 是一个开源的一站式 Agent 聊天机器人平台,可接入主
|
||||
## 主要功能
|
||||
|
||||
1. 💯 免费 & 开源。
|
||||
1. ✨ AI 大模型对话,多模态,Agent,MCP,知识库,人格设定,自动压缩对话。
|
||||
1. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话。
|
||||
2. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
|
||||
2. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
|
||||
3. 📦 插件扩展,已有近 800 个插件可一键安装。
|
||||
|
||||
+28
-19
@@ -1,9 +1,14 @@
|
||||

|
||||
|
||||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
<div>
|
||||
@@ -14,22 +19,17 @@
|
||||
<br>
|
||||
|
||||
<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/badge/python-3.10+-blue.svg?style=for-the-badge&color=76bad9" 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>
|
||||
<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://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&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/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" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<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://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&label=Marketplace&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
<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://blog.astrbot.app/">Blog</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.
|
||||
|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||

|
||||
|
||||
## Key Features
|
||||
|
||||
1. 💯 Free & Open Source.
|
||||
2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Knowledge Base, Persona Settings.
|
||||
3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze and other agent platforms.
|
||||
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.
|
||||
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.
|
||||
6. 💻 WebUI Support.
|
||||
7. 🌐 Internationalization (i18n) 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. 💻 WebUI Support.
|
||||
8. 🌈 Web ChatUI Support with built-in agent sandbox and web search.
|
||||
9. 🌐 Internationalization (i18n) Support.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -208,6 +210,8 @@ pre-commit install
|
||||
- Group 3: 630166526
|
||||
- Group 5: 822130018
|
||||
- Group 6: 753075035
|
||||
- Group 7: 743746109
|
||||
- Group 8: 1030353265
|
||||
- Developer Group: 975206796
|
||||
|
||||
### Telegram Group
|
||||
@@ -243,4 +247,9 @@ Additionally, the birth of this project would not have been possible without the
|
||||
|
||||
</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)
|
||||
|
||||
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:
|
||||
@@ -121,6 +126,9 @@ class ProcessLLMRequest:
|
||||
req.func_tool = toolset
|
||||
else:
|
||||
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()}")
|
||||
|
||||
async def _ensure_img_caption(
|
||||
@@ -225,7 +233,7 @@ class ProcessLLMRequest:
|
||||
# inject persona for this request
|
||||
platform_type = event.get_platform_name()
|
||||
await self._ensure_persona(
|
||||
req, cfg, event.unified_msg_origin, platform_type
|
||||
req, cfg, event.unified_msg_origin, platform_type, event
|
||||
)
|
||||
|
||||
# image caption
|
||||
|
||||
@@ -20,6 +20,8 @@ astrbot_config = AstrBotConfig()
|
||||
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
|
||||
html_renderer = HtmlRenderer(t2i_base_url)
|
||||
logger = LogManager.GetLogger(log_name="astrbot")
|
||||
LogManager.configure_logger(logger, astrbot_config)
|
||||
LogManager.configure_trace_logger(astrbot_config)
|
||||
db_helper = SQLiteDatabase(DB_PATH)
|
||||
# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
|
||||
sp = SharedPreferences(db_helper=db_helper)
|
||||
|
||||
@@ -182,6 +182,12 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
"wake_prefix": ["/"],
|
||||
"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": "",
|
||||
"pypi_index_url": "https://mirrors.aliyun.com/pypi/simple/",
|
||||
"persona": [], # deprecated
|
||||
@@ -2321,6 +2327,18 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"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": {
|
||||
"type": "string",
|
||||
"options": ["remote", "local"],
|
||||
@@ -3253,6 +3271,36 @@ CONFIG_METADATA_3_SYSTEM = {
|
||||
"hint": "控制台输出日志的级别。",
|
||||
"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": {
|
||||
"description": "pip 安装额外参数",
|
||||
"type": "string",
|
||||
|
||||
@@ -17,7 +17,7 @@ import traceback
|
||||
from asyncio import Queue
|
||||
|
||||
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.config.default import VERSION
|
||||
from astrbot.core.conversation_mgr import ConversationManager
|
||||
@@ -80,9 +80,13 @@ class AstrBotCoreLifecycle:
|
||||
# 初始化日志代理
|
||||
logger.info("AstrBot v" + VERSION)
|
||||
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:
|
||||
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()
|
||||
|
||||
|
||||
+19
-61
@@ -6,6 +6,14 @@ from typing import TypedDict
|
||||
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):
|
||||
"""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"
|
||||
|
||||
inner_conversation_id: int | None = Field(
|
||||
@@ -47,11 +55,7 @@ class ConversationV2(SQLModel, table=True):
|
||||
platform_id: str = Field(nullable=False)
|
||||
user_id: str = Field(nullable=False)
|
||||
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)
|
||||
persona_id: str | None = Field(default=None)
|
||||
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,类似于文件系统的目录结构。
|
||||
@@ -92,11 +96,6 @@ class PersonaFolder(SQLModel, table=True):
|
||||
"""父文件夹ID,NULL表示根目录"""
|
||||
description: str | None = Field(default=None, sa_type=Text)
|
||||
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__ = (
|
||||
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.
|
||||
|
||||
It can be used to customize the behavior of LLMs.
|
||||
@@ -131,11 +130,6 @@ class Persona(SQLModel, table=True):
|
||||
"""所属文件夹ID,NULL 表示在根目录"""
|
||||
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__ = (
|
||||
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."""
|
||||
|
||||
__tablename__: str = "preferences"
|
||||
@@ -161,11 +155,6 @@ class Preference(SQLModel, table=True):
|
||||
"""ID of the scope, such as 'global', 'umo', 'plugin_name'."""
|
||||
key: str = Field(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__ = (
|
||||
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.
|
||||
|
||||
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,
|
||||
) # Name of the sender in the platform
|
||||
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.
|
||||
|
||||
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"""
|
||||
is_group: int = Field(default=0, nullable=False)
|
||||
"""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__ = (
|
||||
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.
|
||||
|
||||
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
|
||||
type: str = Field(nullable=False) # Type of the file (e.g., 'image', '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__ = (
|
||||
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.
|
||||
|
||||
Projects allow users to group related conversations together.
|
||||
@@ -310,11 +284,6 @@ class ChatUIProject(SQLModel, table=True):
|
||||
"""Title of the project"""
|
||||
description: str | None = Field(default=None, max_length=1000)
|
||||
"""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__ = (
|
||||
UniqueConstraint(
|
||||
@@ -338,7 +307,6 @@ class SessionProjectRelation(SQLModel, table=True):
|
||||
"""Session ID from PlatformSession"""
|
||||
project_id: str = Field(nullable=False, max_length=36)
|
||||
"""Project ID from ChatUIProject"""
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
__table_args__ = (
|
||||
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."""
|
||||
|
||||
__tablename__ = "command_configs" # type: ignore
|
||||
@@ -368,14 +336,9 @@ class CommandConfig(SQLModel, table=True):
|
||||
note: str | None = Field(default=None, sa_type=Text)
|
||||
extra_data: dict | None = Field(default=None, sa_type=JSON)
|
||||
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."""
|
||||
|
||||
__tablename__ = "command_conflicts" # type: ignore
|
||||
@@ -392,11 +355,6 @@ class CommandConflict(SQLModel, table=True):
|
||||
note: str | None = Field(default=None, sa_type=Text)
|
||||
extra_data: dict | None = Field(default=None, sa_type=JSON)
|
||||
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__ = (
|
||||
UniqueConstraint(
|
||||
|
||||
@@ -54,6 +54,7 @@ class EventBus:
|
||||
event (AstrMessageEvent): 事件对象
|
||||
|
||||
"""
|
||||
event.trace.record("event_dispatch", config_name=conf_name)
|
||||
# 如果有发送者名称: [平台名] 发送者名称/发送者ID: 消息概要
|
||||
if event.get_sender_name():
|
||||
logger.info(
|
||||
|
||||
+189
-1
@@ -27,13 +27,15 @@ import sys
|
||||
import time
|
||||
from asyncio import Queue
|
||||
from collections import deque
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
import colorlog
|
||||
|
||||
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 = {
|
||||
"DEBUG": "green",
|
||||
@@ -163,6 +165,9 @@ class LogManager:
|
||||
提供了获取默认日志记录器logger和设置队列处理器的方法
|
||||
"""
|
||||
|
||||
_FILE_HANDLER_FLAG = "_astrbot_file_handler"
|
||||
_TRACE_FILE_HANDLER_FLAG = "_astrbot_trace_file_handler"
|
||||
|
||||
@classmethod
|
||||
def GetLogger(cls, log_name: str = "default"):
|
||||
"""获取指定名称的日志记录器logger
|
||||
@@ -266,3 +271,186 @@ class LogManager:
|
||||
),
|
||||
)
|
||||
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":
|
||||
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(
|
||||
provider=provider,
|
||||
request=req,
|
||||
@@ -795,12 +806,20 @@ class InternalAgentSubStage(Stage):
|
||||
):
|
||||
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():
|
||||
await self._save_to_history(
|
||||
event,
|
||||
req,
|
||||
agent_runner.get_final_llm_resp(),
|
||||
final_resp,
|
||||
agent_runner.run_context.messages,
|
||||
agent_runner.stats,
|
||||
)
|
||||
|
||||
@@ -85,4 +85,6 @@ class PipelineScheduler:
|
||||
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
|
||||
await event.send(None)
|
||||
|
||||
event.trace.record("event_end")
|
||||
|
||||
logger.debug("pipeline 执行完毕。")
|
||||
|
||||
@@ -4,6 +4,7 @@ import hashlib
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from time import time
|
||||
from typing import Any
|
||||
|
||||
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.provider.entities import ProviderRequest
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
from astrbot.core.utils.trace import TraceSpan
|
||||
|
||||
from .astrbot_message import AstrBotMessage, Group
|
||||
from .message_session import MessageSesion, MessageSession # noqa
|
||||
@@ -59,6 +61,21 @@ class AstrMessageEvent(abc.ABC):
|
||||
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.call_llm = False
|
||||
|
||||
@@ -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))
|
||||
@@ -94,80 +94,9 @@
|
||||
:reasoning="msg.content.reasoning" :is-dark="isDark"
|
||||
:initial-expanded="isReasoningExpanded(index)" />
|
||||
|
||||
<!-- 遍历 message parts (保持顺序) -->
|
||||
<template v-for="(part, partIndex) in msg.content.message" :key="partIndex">
|
||||
<!-- iPython Tool Special Block -->
|
||||
<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>
|
||||
<MessagePartsRenderer :parts="msg.content.message" :is-dark="isDark"
|
||||
:current-time="currentTime" :downloading-files="downloadingFiles"
|
||||
@open-image-preview="openImagePreview" @download-file="downloadFile" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="message-actions" v-if="!msg.content.isLoading || index === messages.length - 1">
|
||||
@@ -250,14 +179,13 @@
|
||||
|
||||
<script>
|
||||
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 'katex/dist/katex.min.css'
|
||||
import 'highlight.js/styles/github.css';
|
||||
import axios from 'axios';
|
||||
import ReasoningBlock from './message_list_comps/ReasoningBlock.vue';
|
||||
import IPythonToolBlock from './message_list_comps/IPythonToolBlock.vue';
|
||||
import ToolCallCard from './message_list_comps/ToolCallCard.vue';
|
||||
import MessagePartsRenderer from './message_list_comps/MessagePartsRenderer.vue';
|
||||
import RefNode from './message_list_comps/RefNode.vue';
|
||||
import ActionRef from './message_list_comps/ActionRef.vue';
|
||||
|
||||
@@ -270,10 +198,8 @@ setCustomComponents('message-list', { ref: RefNode });
|
||||
export default {
|
||||
name: 'MessageList',
|
||||
components: {
|
||||
MarkdownRender,
|
||||
ReasoningBlock,
|
||||
IPythonToolBlock,
|
||||
ToolCallCard,
|
||||
MessagePartsRenderer,
|
||||
RefNode,
|
||||
ActionRef
|
||||
},
|
||||
@@ -319,8 +245,6 @@ export default {
|
||||
scrollTimer: null,
|
||||
expandedReasoning: new Set(), // Track which reasoning blocks are expanded
|
||||
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
|
||||
currentTime: Date.now() / 1000, // Current time for elapsed time calculation
|
||||
// 选中文本相关状态
|
||||
@@ -541,23 +465,6 @@ export default {
|
||||
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) {
|
||||
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
|
||||
startElapsedTimeTimer() {
|
||||
// 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)
|
||||
getInputTokens(tokenUsage) {
|
||||
if (!tokenUsage) return 0;
|
||||
@@ -943,11 +822,6 @@ export default {
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// Check if tool is iPython executor
|
||||
isIPythonTool(toolCall) {
|
||||
return toolCall.name === 'astrbot_execute_ipython' || toolCall.name === 'astrbot_execute_python';
|
||||
},
|
||||
|
||||
// Open refs sidebar
|
||||
openRefsSidebar(refs) {
|
||||
this.$emit('openRefs', refs);
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
<template>
|
||||
<div class="mb-3 mt-1.5">
|
||||
<div class="ipython-header" :class="{ 'expanded': isExpanded }" @click="toggleExpanded">
|
||||
<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">
|
||||
<div class="ipython-tool-block" :class="{ compact: !showHeader }">
|
||||
<div v-if="displayExpanded" class="py-3 animate-fade-in">
|
||||
<!-- Code Section -->
|
||||
<div class="code-section">
|
||||
<div v-if="shikiReady && code" class="code-highlighted"
|
||||
@@ -46,6 +38,14 @@ const props = defineProps({
|
||||
initialExpanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
forceExpanded: {
|
||||
type: Boolean,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
@@ -92,9 +92,12 @@ const highlightedCode = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const toggleExpanded = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
const displayExpanded = computed(() => {
|
||||
if (props.forceExpanded === null) {
|
||||
return isExpanded.value;
|
||||
}
|
||||
return props.forceExpanded;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -110,40 +113,13 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mb-3 {
|
||||
.ipython-tool-block {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mt-1\.5 {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.ipython-header {
|
||||
display: inline-flex;
|
||||
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);
|
||||
.ipython-tool-block.compact {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.py-3 {
|
||||
@@ -160,6 +136,7 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-fallback {
|
||||
@@ -208,6 +185,10 @@ onMounted(async () => {
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
:deep(.code-highlighted pre) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
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>
|
||||
@@ -46,6 +46,7 @@ export class I18nLoader {
|
||||
{ name: 'features/config', path: 'features/config.json' },
|
||||
{ name: 'features/config-metadata', path: 'features/config-metadata.json' },
|
||||
{ name: 'features/console', path: 'features/console.json' },
|
||||
{ name: 'features/trace', path: 'features/trace.json' },
|
||||
{ name: 'features/about', path: 'features/about.json' },
|
||||
{ name: 'features/settings', path: 'features/settings.json' },
|
||||
{ name: 'features/auth', path: 'features/auth.json' },
|
||||
@@ -295,4 +296,4 @@ export class I18nLoader {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"conversation": "Conversations",
|
||||
"sessionManagement": "Custom Rules",
|
||||
"console": "Console",
|
||||
"trace": "Trace",
|
||||
"alkaid": "Alkaid Lab",
|
||||
"knowledgeBase": "Knowledge Base",
|
||||
"about": "About",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"reply": "Reply",
|
||||
"providerConfig": "AI Configuration",
|
||||
"toolsUsed": "Tool Used",
|
||||
"toolCallUsed": "Used {name} tool",
|
||||
"pythonCodeAnalysis": "Python Code Analysis Used"
|
||||
},
|
||||
"ipython": {
|
||||
@@ -133,4 +134,4 @@
|
||||
"sendMessageFailed": "Failed to send message, please try again",
|
||||
"createSessionFailed": "Failed to create session, please refresh the page"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,6 +564,30 @@
|
||||
"description": "Console Log Level",
|
||||
"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": {
|
||||
"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`."
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": "Trace",
|
||||
"autoScroll": {
|
||||
"enabled": "Auto-scroll: On",
|
||||
"disabled": "Auto-scroll: Off"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"conversation": "对话数据",
|
||||
"sessionManagement": "自定义规则",
|
||||
"console": "平台日志",
|
||||
"trace": "追踪",
|
||||
"alkaid": "Alkaid",
|
||||
"knowledgeBase": "知识库",
|
||||
"about": "关于",
|
||||
@@ -30,4 +31,4 @@
|
||||
"selectVersion": "选择版本",
|
||||
"current": "当前"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"reply": "引用回复",
|
||||
"providerConfig": "AI 配置",
|
||||
"toolsUsed": "已使用工具",
|
||||
"toolCallUsed": "已使用 {name} 工具",
|
||||
"pythonCodeAnalysis": "已使用 Python 代码分析"
|
||||
},
|
||||
"ipython": {
|
||||
@@ -135,4 +136,4 @@
|
||||
"sendMessageFailed": "发送消息失败,请重试",
|
||||
"createSessionFailed": "创建会话失败,请刷新页面重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,6 +562,30 @@
|
||||
"description": "控制台日志级别",
|
||||
"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": {
|
||||
"description": "pip 安装额外参数",
|
||||
"hint": "安装插件依赖时,会使用 Python 的 pip 工具。这里可以填写额外的参数,如 `--break-system-package` 等。"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": "追踪",
|
||||
"autoScroll": {
|
||||
"enabled": "自动滚动:开",
|
||||
"disabled": "自动滚动:关"
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import zhCNPlatform from './locales/zh-CN/features/platform.json';
|
||||
import zhCNConfig from './locales/zh-CN/features/config.json';
|
||||
import zhCNConfigMetadata from './locales/zh-CN/features/config-metadata.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 zhCNSettings from './locales/zh-CN/features/settings.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 enUSConfigMetadata from './locales/en-US/features/config-metadata.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 enUSSettings from './locales/en-US/features/settings.json';
|
||||
import enUSAuth from './locales/en-US/features/auth.json';
|
||||
@@ -97,6 +99,7 @@ export const translations = {
|
||||
config: zhCNConfig,
|
||||
'config-metadata': zhCNConfigMetadata,
|
||||
console: zhCNConsole,
|
||||
trace: zhCNTrace,
|
||||
about: zhCNAbout,
|
||||
settings: zhCNSettings,
|
||||
auth: zhCNAuth,
|
||||
@@ -142,6 +145,7 @@ export const translations = {
|
||||
config: enUSConfig,
|
||||
'config-metadata': enUSConfigMetadata,
|
||||
console: enUSConsole,
|
||||
trace: enUSTrace,
|
||||
about: enUSAbout,
|
||||
settings: enUSSettings,
|
||||
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',
|
||||
to: '/console'
|
||||
},
|
||||
{
|
||||
title: 'core.navigation.trace',
|
||||
icon: 'mdi-timeline-text-outline',
|
||||
to: '/trace'
|
||||
},
|
||||
]
|
||||
}
|
||||
// {
|
||||
|
||||
@@ -61,6 +61,11 @@ const MainRoutes = {
|
||||
path: '/console',
|
||||
component: () => import('@/views/ConsolePage.vue')
|
||||
},
|
||||
{
|
||||
name: 'Trace',
|
||||
path: '/trace',
|
||||
component: () => import('@/views/TracePage.vue')
|
||||
},
|
||||
{
|
||||
name: 'NativeKnowledgeBase',
|
||||
path: '/knowledge-base',
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user