feat: add trace metadata chips and preserve response ownership

This commit is contained in:
Soulter
2026-10-12 13:44:08 +08:00
parent 2c56e33f89
commit 3105bb355a
12 changed files with 148 additions and 15 deletions
@@ -52,7 +52,7 @@ class AgentRequestSubStage(Stage):
agent_started = time.perf_counter()
previous_source = event.get_extra("_trace_response_source")
event.set_extra("_trace_response_source", "agent")
event.set_extra("_trace_response_source", "core")
agent_config = self.config.get("agent_runner", {})
event.trace.record(
"agent_start",
+1 -1
View File
@@ -239,7 +239,7 @@ class RespondStage(Stage):
)
event.set_extra(
"_trace_response_source",
"agent" if result.is_model_result() else "core",
event.get_extra("_trace_response_source") or "core",
)
if result.result_content_type != ResultContentType.STREAMING_RESULT:
event.trace.record(
+1
View File
@@ -17,3 +17,4 @@
- Collapse consecutive same-type timeline nodes with stacked icons and visible error counts.
- Record only the final streaming-response summary, excluding adapter chunks and protocol closing frames while retaining independent tool/plugin replies.
- Keep the existing in-memory trace storage; no new database persistence.
- Show execution mode, plugin names, and response ownership as timeline chips. Reuse profile runner labels and classify built-in Agent replies as system/core while preserving plugin senders.
@@ -20,6 +20,7 @@ import { getPlatformIcon } from "@/utils/platformUtils";
import defaultPluginIcon from "/favicon.svg";
const { tm } = useModuleI18n("features/trace");
const { tm: configTm } = useModuleI18n("features/config");
const maxItems = 300;
const events = ref([]);
const selectedSpanId = ref(null);
@@ -152,9 +153,13 @@ const timelineRows = computed(() => timelineGroups.value.flatMap((group) => {
const errors = group.records.filter((record) => record.fields.status === "error").length;
const plugins = [...new Set(group.records.map((record) =>
record.fields.plugin_display_name || record.fields.plugin,
).filter(Boolean))].join(" · ");
).filter(Boolean))].join(", ");
const metadata = [...new Set(group.records.filter((record) =>
record.action === "agent_start" || record.kind === "response",
).map((record) => record.action === "agent_start"
? tm("chips.executionMode", { name: recordSubtitle(record) }) : recordSubtitle(record)))];
return [
{ key: `${group.key}:group`, record: group.records[0], group, expanded, errors, plugins },
{ key: `${group.key}:group`, record: group.records[0], group, expanded, errors, plugins, metadata },
...(expanded ? group.records.map((record) => ({ key: record.key, record, nested: true })) : []),
];
}));
@@ -430,9 +435,20 @@ function recordSubtitle(record) {
return `${fields.plugin_display_name || fields.plugin || "Plugin"} · ${fields.handler || "handler"} · ${fields.handler_type || ""}`;
}
if (record.action === "stage_start" || record.action === "stage_end") return fields.stage || "";
if (record.action === "response_sent") return `${fields.source || "response"} · ${fields.message || ""}`;
if (record.action === "response_ready") return `${fields.source || "response"} · ${fields.message || ""}`;
if (record.action === "agent_start") return fields.runner_type || "agent";
if (record.action === "response_sent" || record.action === "response_ready") {
const source = fields.source;
if (source?.startsWith("plugin:")) {
const plugin = source.slice("plugin:".length);
return tm("chips.pluginSender", { name: plugins.value[plugin]?.display_name || plugin });
}
return tm(source === "core" || source === "agent" ? "chips.systemSender" : "chips.unknownSender");
}
if (record.action === "agent_start") {
const runner = fields.runner_type || "local";
if (runner === "local") return configTm("aiSettings.localSettingsTitle");
return ["dify", "coze", "dashscope", "deerflow"].includes(runner)
? configTm(`aiSettings.runners.${runner}.title`) : runner;
}
if (record.action === "agent_tool_call") return fields.tool_name?.name || fields.tool_name || "tool";
if (record.action === "event_received") return fields.message || "";
if (record.fields?.error) return record.fields.error;
@@ -614,7 +630,8 @@ onBeforeUnmount(() => {
<time>{{ formatTime(row.record.time) }}</time>
</div>
<div class="timeline-subtitle timeline-group-summary">
<span v-if="row.plugins" class="timeline-group-plugins">{{ row.plugins }}</span>
<v-chip v-if="row.plugins" class="timeline-metadata-chip" size="x-small" variant="tonal" color="primary" :title="row.plugins">{{ row.plugins }}</v-chip>
<v-chip v-for="label in row.metadata" :key="label" class="timeline-metadata-chip" size="x-small" variant="tonal">{{ label }}</v-chip>
<span class="timeline-group-expansion"><ChevronDown :size="13" :class="{ 'is-collapsed': !row.expanded }" aria-hidden="true" />{{ tm(row.expanded ? "collapse" : "expand") }}</span>
</div>
</button>
@@ -623,7 +640,7 @@ onBeforeUnmount(() => {
<div v-if="record.kind === 'plugin_handler'" class="timeline-subtitle">
<v-tooltip :text="record.fields.plugin" location="top">
<template #activator="{ props }">
<span v-bind="props" class="trace-hover-name">{{ record.fields.plugin_display_name || record.fields.plugin || "Plugin" }}</span>
<v-chip v-bind="props" class="trace-hover-name timeline-metadata-chip" size="x-small" variant="tonal" color="primary">{{ record.fields.plugin_display_name || record.fields.plugin || "Plugin" }}</v-chip>
</template>
</v-tooltip>
·
@@ -641,9 +658,12 @@ onBeforeUnmount(() => {
<span v-if="record.fields.duration_ms != null"> · {{ formatDuration(record.fields.duration_ms) }}</span>
</div>
<div v-else-if="record.kind === 'response'" class="timeline-subtitle">
<span>{{ record.fields.source || "response" }}</span>
<v-chip class="timeline-metadata-chip" size="x-small" variant="tonal" :title="record.fields.source">{{ recordSubtitle(record) }}</v-chip>
<div v-if="record.fields.message" class="trace-response-outline">{{ record.fields.message }}</div>
</div>
<div v-else-if="record.action === 'agent_start'" class="timeline-subtitle">
<v-chip class="timeline-metadata-chip" size="x-small" variant="tonal" color="primary">{{ tm("chips.executionMode", { name: recordSubtitle(record) }) }}</v-chip>
</div>
<div v-else-if="recordSubtitle(record)" class="timeline-subtitle">{{ recordSubtitle(record) }}</div>
<details v-if="formatFields(record.fields)" class="timeline-fields"><summary>{{ tm("details") }}</summary><pre>{{ formatFields(record.fields) }}</pre></details>
</template>
@@ -672,8 +692,9 @@ onBeforeUnmount(() => {
.timeline-group-title { align-items: center; display: flex; flex-wrap: wrap; gap: 6px; }
.timeline-group-count { background: rgba(var(--v-theme-on-surface), 0.06); border-radius: 4px; color: var(--trace-muted); font-size: 10px; padding: 1px 5px; white-space: nowrap; }
.timeline-group-errors { color: #ef4444; font-size: 10px; white-space: nowrap; }
.timeline-group-summary { align-items: center; display: flex; gap: 10px; }
.timeline-group-plugins { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.timeline-group-summary { align-items: center; display: flex; flex-wrap: wrap; gap: 6px 10px; }
.timeline-metadata-chip { max-width: 100%; vertical-align: middle; }
.timeline-metadata-chip :deep(.v-chip__content) { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.timeline-group-expansion { align-items: center; display: inline-flex; flex: 0 0 auto; font-size: 10px; gap: 3px; }
.timeline-group-expansion svg { transition: transform 0.15s ease; }
.timeline-group-expansion .is-collapsed { transform: rotate(-90deg); }
@@ -33,6 +33,12 @@
"responses": "Responses",
"details": "View details",
"selectTrace": "Select a trace to inspect the complete execution chain",
"chips": {
"executionMode": "Execution mode: {name}",
"systemSender": "Sent by system",
"pluginSender": "Sent by plugin: {name}",
"unknownSender": "Unknown sender"
},
"groups": {
"handlerStart": "Multiple plugin handlers triggered",
"agentStart": "Multiple Agents started",
@@ -33,6 +33,12 @@
"responses": "レスポンス",
"details": "詳細を表示",
"selectTrace": "トレースを選択すると実行チェーンを確認できます",
"chips": {
"executionMode": "実行方式: {name}",
"systemSender": "システムから送信",
"pluginSender": "プラグインから送信: {name}",
"unknownSender": "送信元不明"
},
"groups": {
"handlerStart": "複数のプラグインハンドラーを実行",
"agentStart": "複数の Agent を開始",
@@ -33,6 +33,12 @@
"responses": "Ответы",
"details": "Подробности",
"selectTrace": "Выберите трассировку, чтобы увидеть цепочку выполнения",
"chips": {
"executionMode": "Режим выполнения: {name}",
"systemSender": "Отправлено системой",
"pluginSender": "Отправлено плагином: {name}",
"unknownSender": "Неизвестный отправитель"
},
"groups": {
"handlerStart": "Вызваны несколько обработчиков плагинов",
"agentStart": "Запущены несколько Agent",
@@ -33,6 +33,12 @@
"responses": "响应",
"details": "查看详情",
"selectTrace": "选择一条追踪查看完整执行链",
"chips": {
"executionMode": "执行方式:{name}",
"systemSender": "系统发送",
"pluginSender": "插件发送:{name}",
"unknownSender": "来源未知"
},
"groups": {
"handlerStart": "触发多个插件处理器",
"agentStart": "启动多个 Agent",
+30 -3
View File
@@ -16,11 +16,20 @@ const translations = JSON.parse(readFileSync(
new URL("../src/i18n/locales/zh-CN/features/trace.json", import.meta.url),
"utf8",
));
const configTranslations = JSON.parse(readFileSync(
new URL("../src/i18n/locales/zh-CN/features/config.json", import.meta.url), "utf8",
));
function setup(t, records) {
const selectedEvent = ref({ records, first_time: 1, last_time: 2 });
const context = vm.createContext({
computed, reactive, ref, watch, selectedEvent,
plugins: ref({ weather: { display_name: "Weather Forecast" } }),
configTm(key) {
const text = key.split(".").reduce((value, part) => value?.[part], configTranslations);
assert.equal(typeof text, "string", `Missing config translation: ${key}`);
return text;
},
tm(key, params = {}) {
const text = key.split(".").reduce((value, part) => value?.[part], translations);
assert.equal(typeof text, "string", `Missing translation: ${key}`);
@@ -30,7 +39,7 @@ function setup(t, records) {
const names = new Set([
"selectedSpanId", "expandedGroups", "actionLabels", "selectedStats",
"selectedRecords", "timelineGroups", "timelineRows", "timelineRecords",
"recordLabel", "formatDuration",
"recordLabel", "recordSubtitle", "formatDuration",
]);
const statements = ast.statements.filter((node) =>
ts.isFunctionDeclaration(node) ? names.has(node.name?.text) :
@@ -41,7 +50,7 @@ function setup(t, records) {
vm.runInContext(statements.map((node) => node.getText(ast)).join("\n"), context);
const stop = vm.runInContext(watcher.getText(ast), context);
t.after(stop);
return vm.runInContext("({ selectedEvent, selectedSpanId, selectedStats, selectedRecords, timelineGroups, timelineRows, expandedGroups, recordLabel })", context);
return vm.runInContext("({ selectedEvent, selectedSpanId, selectedStats, selectedRecords, timelineGroups, timelineRows, expandedGroups, recordLabel, recordSubtitle })", context);
}
function handler(key, category = "listener", fields = {}) {
@@ -68,7 +77,7 @@ test("collapse adjacent handlers by category without reordering separated record
const listener = state.timelineRows.value[1];
assert.equal(listener.expanded, false);
assert.equal(listener.group.records.length, 2);
assert.equal(listener.plugins, "Test Plugin · Another Plugin");
assert.equal(listener.plugins, "Test Plugin, Another Plugin");
assert.equal(state.recordLabel(listener.record, true), "触发多个插件处理器 (事件监听器)");
assert.equal(state.recordLabel(state.timelineRows.value[2].record, true), "触发多个插件处理器 (事件钩子)");
assert.equal(state.selectedStats.value.handlers, 5);
@@ -120,6 +129,22 @@ test("empty and singleton timelines do not create group headers", (t) => {
assert.equal(state.recordLabel(state.timelineRows.value[0].record), "触发插件处理器 (指令/指令组)");
});
test("chips reuse profile runner names and classify responses by their sending owner", (t) => {
const state = setup(t, [
{ key: "agent1", action: "agent_start", kind: "agent", fields: { runner_type: "local" } },
{ key: "agent2", action: "agent_start", kind: "agent", fields: { runner_type: "dify" } },
{ key: "system", action: "response_sent", kind: "response", fields: { source: "core" } },
{ key: "legacy", action: "response_sent", kind: "response", fields: { source: "agent" } },
{ key: "plugin", action: "response_sent", kind: "response", fields: { source: "plugin:weather" } },
]);
const rows = state.timelineRows.value;
assert.deepEqual(Array.from(rows[0].metadata), ["执行方式:AstrBot 内置 AI", "执行方式:Dify"]);
assert.deepEqual(Array.from(rows[1].metadata), ["系统发送", "插件发送:Weather Forecast"]);
assert.equal(state.recordSubtitle({ action: "agent_start", fields: { runner_type: "custom_runner" } }), "custom_runner");
assert.equal(state.recordSubtitle({ action: "response_sent", fields: { source: "unknown" } }), "来源未知");
assert.equal(state.recordSubtitle({ action: "response_sent", fields: { source: "plugin:uninstalled" } }), "插件发送:uninstalled");
});
test("group template and stacked icon styles compile, and all locales include group labels", () => {
const template = compileTemplate({ source: descriptor.template.content, filename: "TraceDisplayer.vue", id: "trace-groups" });
assert.deepEqual(template.errors, []);
@@ -127,8 +152,10 @@ test("group template and stacked icon styles compile, and all locales include gr
assert.deepEqual(styles.errors, []);
assert.match(descriptor.template.content, /:aria-expanded="row.expanded"/);
assert.match(descriptor.template.content, /row.group.records.slice\(0, 3\)/);
assert.match(descriptor.template.content, /<v-chip[^>]*timeline-metadata-chip/);
for (const locale of ["en-US", "ja-JP", "ru-RU"]) {
const data = JSON.parse(readFileSync(new URL(`../src/i18n/locales/${locale}/features/trace.json`, import.meta.url), "utf8").replace(/^\uFEFF/, ""));
assert.deepEqual(Object.keys(data.groups).sort(), Object.keys(translations.groups).sort());
assert.deepEqual(Object.keys(data.chips).sort(), Object.keys(translations.chips).sort());
}
});
+2
View File
@@ -183,6 +183,8 @@ Handler names also have a dotted underline. Hover to lazily fetch plugin details
Streaming responses show one final aggregated response after delivery finishes, without intermediate chunks or protocol closing frames. Messages sent independently by tools or plugins during generation remain separate responses.
Timeline chips show execution mode using the profile page's labels (`local` uses the AstrBot built-in AI title), plugin names (comma-separated in collapsed groups), and response ownership. Built-in Agent replies are system responses (`source: core`); plugin replies use `source: plugin:<plugin ID>` and show the plugin's display name.
Consecutive nodes of the same type are collapsed by default, such as "Multiple plugin handlers triggered (Event listener)", with a record count and stacked icons. Click to expand or collapse the individual records. Different handler categories and records separated by another node type remain separate, preserving execution order. Collapsed groups still show error counts, and summary cards continue to count actual calls.
The message list and message-received node show the platform logo. The message-received title includes `platform_id` in parentheses. A generic icon is used when no logo matches or the image fails to load; text still indicates processing status. The `sel_persona` node is labeled "Select persona". The search hint is "Search messages, sessions, or plugins". Summary cards show duration, plugin handler calls, and responses, without counting steps.
+2
View File
@@ -183,6 +183,8 @@ UMO 有别名时显示别名,带点划线的文字可悬停查看完整 UMO。
流式响应在发送结束后仅展示一条最终汇总的“发送响应”,不展示中间分片或协议收尾帧。流式生成期间,工具或插件单独发送的消息仍会作为独立响应展示。
时间线使用标签展示元信息:Agent 节点的“执行方式”沿用配置文件页名称,`local` 显示为“AstrBot 内置 AI”;插件处理器显示插件名称,折叠组中的多个名称以逗号分隔。响应节点显示“系统发送”或“插件发送:插件名”;内置 Agent 的响应也归为系统(`source: core`),插件响应使用 `source: plugin:插件ID`。
时间线上连续的同类型节点默认折叠,例如“触发多个插件处理器(事件监听器)”,显示记录数量及叠放图标;点击可展开或收起每条记录。不同处理器分类或被其他类型节点隔开的记录不会合并,执行顺序保持不变。组内有错误时,折叠标题仍会提示错误数量,统计卡片仍按实际调用次数计算。
左侧消息列表及“收到消息”节点显示平台 logo;“收到消息”标题括号内显示 `platform_id`。没有匹配图标或图片加载失败时使用通用图标,处理状态仍通过文字显示。人格选择节点 `sel_persona` 显示为“选择人格”。搜索框提示为“搜索消息、会话、插件”,统计卡片显示耗时、插件处理器次数和发送响应次数,不展示步骤数。
+56
View File
@@ -125,6 +125,7 @@ async def test_streaming_trace_only_records_final_response(
["Tool reply", "Hello world"] if tool_response else ["Hello world"]
)
assert records[-1].kwargs["streaming"] is True
assert records[-1].kwargs["source"] == "core"
assert streaming_response_event.get() is None
await event.send(MessageChain([Plain("Normal reply")]))
assert event.trace.record.call_args.kwargs["message"] == "Normal reply"
@@ -156,6 +157,61 @@ async def test_streaming_trace_context_restored_after_failure(
assert streaming_response_event.get() is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
"result_type",
[
ResultContentType.GENERAL_RESULT,
ResultContentType.LLM_RESULT,
ResultContentType.STREAMING_RESULT,
],
)
@pytest.mark.parametrize("source", [None, "core", "plugin:test_plugin"])
async def test_response_trace_preserves_sending_owner(
astr_message_event, monkeypatch, result_type, source
):
"""Attribute system/model replies to core while preserving plugin ownership."""
event = astr_message_event
event.trace = MagicMock()
monkeypatch.setattr(
"astrbot.core.platform.astr_message_event.Metric.upload", AsyncMock()
)
monkeypatch.setattr(
"astrbot.core.pipeline.respond.stage.call_event_hook",
AsyncMock(return_value=False),
)
if source is not None:
event.set_extra("_trace_response_source", source)
async def stream():
yield MessageChain([Plain("Reply")])
async def deliver(generator, use_fallback):
async for chunk in generator:
await event.send(chunk)
result = MessageEventResult().set_result_content_type(result_type)
if result_type == ResultContentType.STREAMING_RESULT:
result.set_async_stream(stream())
event.send_streaming = AsyncMock(side_effect=deliver)
else:
result.message("Reply")
event.set_result(result)
stage = RespondStage()
stage.config = {"provider_settings": {}}
stage.platform_settings = {"path_mapping": []}
stage.enable_seg = False
await stage.process(event)
sent = [
record for record in event.trace.record.call_args_list
if record.args[0] == "response_sent"
]
assert len(sent) == 1
assert sent[0].kwargs["source"] == (source or "core")
assert sent[0].kwargs["message"] == "Reply"
def test_retained_attachment_is_excluded_from_event_cleanup(
astr_message_event, tmp_path
):