Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions astrbot/core/astr_agent_run_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ async def run_agent(
return
if resp.type == "tool_call_result":
msg_chain = resp.data["chain"]

astr_event.trace.record(
"agent_tool_result",
tool_result=msg_chain.get_plain_text(
with_other_comps_mark=True
),
)

if msg_chain.type == "tool_direct_result":
# tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容
await astr_event.send(msg_chain)
Expand All @@ -67,12 +75,22 @@ async def run_agent(
# 用来标记流式响应需要分节
yield MessageChain(chain=[], type="break")

tool_info = None

if resp.data["chain"].chain:
json_comp = resp.data["chain"].chain[0]
if isinstance(json_comp, Json):
tool_info = json_comp.data
astr_event.trace.record(
"agent_tool_call",
tool_name=tool_info if tool_info else "unknown",
)

if astr_event.get_platform_name() == "webchat":
await astr_event.send(resp.data["chain"])
elif show_tool_use:
json_comp = resp.data["chain"].chain[0]
if isinstance(json_comp, Json):
m = f"🔨 调用工具: {json_comp.data.get('name')}"
if tool_info:
m = f"🔨 调用工具: {tool_info.get('name', 'unknown')}"
else:
m = "🔨 调用工具..."
chain = MessageChain(type="tool_call").message(m)
Expand Down
1 change: 1 addition & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@
"log_file_enable": False,
"log_file_path": "logs/astrbot.log",
"log_file_max_mb": 20,
"trace_enable": False,
"trace_log_enable": False,
"trace_log_path": "logs/astrbot.trace.log",
"trace_log_max_mb": 20,
Expand Down
1 change: 0 additions & 1 deletion astrbot/core/event_bus.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ def _print_event(self, event: AstrMessageEvent, conf_name: str):
event (AstrMessageEvent): 事件对象

"""
event.trace.record("event_dispatch", config_name=conf_name)
# 如果有发送者名称: [平台名] 发送者名称/发送者ID: 消息概要
if event.get_sender_name():
logger.info(
Expand Down
24 changes: 21 additions & 3 deletions astrbot/core/message/message_event_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
AtAll,
BaseMessageComponent,
Image,
Json,
Plain,
)

Expand Down Expand Up @@ -117,9 +118,26 @@ def use_t2i(self, use_t2i: bool):
self.use_t2i_ = use_t2i
return self

def get_plain_text(self) -> str:
"""获取纯文本消息。这个方法将获取 chain 中所有 Plain 组件的文本并拼接成一条消息。空格分隔。"""
return " ".join([comp.text for comp in self.chain if isinstance(comp, Plain)])
def get_plain_text(self, with_other_comps_mark: bool = False) -> str:
"""获取纯文本消息。这个方法将获取 chain 中所有 Plain 组件的文本并拼接成一条消息。空格分隔。

Args:
with_other_comps_mark (bool): 是否在纯文本中标记其他组件的位置
"""
if not with_other_comps_mark:
return " ".join(
[comp.text for comp in self.chain if isinstance(comp, Plain)]
)
else:
texts = []
for comp in self.chain:
if isinstance(comp, Plain):
texts.append(comp.text)
elif isinstance(comp, Json):
texts.append(f"{comp.data}")
else:
texts.append(f"[{comp.__class__.__name__}]")
return " ".join(texts)

def squash_plain(self):
"""将消息链中的所有 Plain 消息段聚合到第一个 Plain 消息段中。"""
Expand Down
2 changes: 0 additions & 2 deletions astrbot/core/pipeline/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,4 @@ async def execute(self, event: AstrMessageEvent):
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
await event.send(None)

event.trace.record("event_end")

logger.debug("pipeline 执行完毕。")
3 changes: 0 additions & 3 deletions astrbot/core/platform/astr_message_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,6 @@ def __init__(
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
Expand Down
4 changes: 4 additions & 0 deletions astrbot/core/utils/trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ def __init__(
self.started_at = time.time()

def record(self, action: str, **fields: Any) -> None:
# Check if trace recording is enabled
if not astrbot_config.get("trace_enable", True):
return

payload = {
"type": "trace",
"level": "TRACE",
Expand Down
36 changes: 36 additions & 0 deletions astrbot/dashboard/routes/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ def __init__(self, context: RouteContext, log_broker: LogBroker) -> None:
view_func=self.log_history,
methods=["GET"],
)
self.app.add_url_rule(
"/api/trace/settings",
view_func=self.get_trace_settings,
methods=["GET"],
)
self.app.add_url_rule(
"/api/trace/settings",
view_func=self.update_trace_settings,
methods=["POST"],
)

async def _replay_cached_logs(
self, last_event_id: str
Expand Down Expand Up @@ -106,3 +116,29 @@ async def log_history(self):
except Exception as e:
logger.error(f"获取日志历史失败: {e}")
return Response().error(f"获取日志历史失败: {e}").__dict__

async def get_trace_settings(self):
"""获取 Trace 设置"""
try:
trace_enable = self.config.get("trace_enable", True)
return Response().ok(data={"trace_enable": trace_enable}).__dict__
except Exception as e:
logger.error(f"获取 Trace 设置失败: {e}")
return Response().error(f"获取 Trace 设置失败: {e}").__dict__

async def update_trace_settings(self):
"""更新 Trace 设置"""
try:
data = await request.json
if data is None:
return Response().error("请求数据为空").__dict__

trace_enable = data.get("trace_enable")
if trace_enable is not None:
self.config["trace_enable"] = bool(trace_enable)
Comment on lines +136 to +138
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): 使用 bool(trace_enable) 时,像 "false" 这样的非空字符串会被当作 True,从而反转预期的设置。

如果前端发送的是字符串 "false" 或 "0" 之类的值,bool(trace_enable) 依然会是 True,导致保存下来的配置和用户的选择不一致。建议在 trace_enable 已经是布尔值时直接使用它,或者对常见字符串取值进行规范化处理(例如不区分大小写地判断 "true"/"false")后再存储。

Original comment in English

issue (bug_risk): Using bool(trace_enable) will treat non-empty strings like "false" as True, which can invert the intended setting.

If the frontend ever sends values like the strings "false" or "0", bool(trace_enable) will still be True, so the saved config may not match the user’s choice. Consider either using the value directly when trace_enable is already a bool, or normalizing known string values (e.g., case-insensitive checks for "true"/"false") before storing.

self.config.save_config()

return Response().ok(message="Trace 设置已更新").__dict__
except Exception as e:
logger.error(f"更新 Trace 设置失败: {e}")
return Response().error(f"更新 Trace 设置失败: {e}").__dict__
5 changes: 4 additions & 1 deletion dashboard/src/i18n/locales/en-US/features/trace.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@
"autoScroll": {
"enabled": "Auto-scroll: On",
"disabled": "Auto-scroll: Off"
}
},
"hint": "Currently only recording partial model call paths from AstrBot main Agent. More coverage will be added.",
"recording": "Recording",
"paused": "Paused"
}
5 changes: 4 additions & 1 deletion dashboard/src/i18n/locales/zh-CN/features/trace.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@
"autoScroll": {
"enabled": "自动滚动:开",
"disabled": "自动滚动:关"
}
},
"hint": "当前仅记录部分 AstrBot 主 Agent 的模型调用路径,后续会不断完善。",
"recording": "记录中",
"paused": "已暂停"
}
98 changes: 96 additions & 2 deletions dashboard/src/views/TracePage.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,72 @@
<script setup>
import TraceDisplayer from '@/components/shared/TraceDisplayer.vue';
import { useModuleI18n } from '@/i18n/composables';
import { ref, onMounted } from 'vue';
import axios from 'axios';

const { tm } = useModuleI18n('features/trace');

const traceEnabled = ref(true);
const loading = ref(false);
const traceDisplayerKey = ref(0);

const fetchTraceSettings = async () => {
try {
const res = await axios.get('/api/trace/settings');
if (res.data?.status === 'ok') {
traceEnabled.value = res.data.data?.trace_enable ?? true;
}
} catch (err) {
console.error('Failed to fetch trace settings:', err);
}
};

const updateTraceSettings = async () => {
loading.value = true;
try {
await axios.post('/api/trace/settings', {
trace_enable: traceEnabled.value
});
// Refresh the TraceDisplayer component to reconnect SSE
traceDisplayerKey.value += 1;
} catch (err) {
console.error('Failed to update trace settings:', err);
} finally {
loading.value = false;
}
};

onMounted(() => {
fetchTraceSettings();
});
</script>

<template>
<div style="height: 100%;">
<TraceDisplayer />
<div style="height: 100%; display: flex; flex-direction: column;">
<div class="trace-header">
<div class="trace-info">
<v-icon size="small" color="info" class="mr-2">mdi-information-outline</v-icon>
<span class="trace-hint">{{ tm('hint') }}</span>
</div>
<div class="trace-controls">
<v-switch
v-model="traceEnabled"
:loading="loading"
:disabled="loading"
color="primary"
hide-details
density="compact"
@update:model-value="updateTraceSettings"
>
<template #label>
<span class="switch-label">{{ traceEnabled ? tm('recording') : tm('paused') }}</span>
</template>
</v-switch>
</div>
</div>
<div style="flex: 1; min-height: 0;">
<TraceDisplayer :key="traceDisplayerKey" />
</div>
</div>
</template>

Expand All @@ -19,3 +78,38 @@ export default {
}
};
</script>

<style scoped>
.trace-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(59, 130, 246, 0.05);
border-bottom: 1px solid rgba(59, 130, 246, 0.1);
border-radius: 8px 8px 0 0;
margin-bottom: 8px;
}

.trace-info {
display: flex;
align-items: center;
}

.trace-hint {
font-size: 13px;
color: #6b7280;
}

.trace-controls {
display: flex;
align-items: center;
gap: 8px;
}

.switch-label {
font-size: 13px;
color: #4b5563;
white-space: nowrap;
}
</style>