mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 15:06:04 +08:00
feat: unify trace toolbar controls and use Vuetify datatable
This commit is contained in:
@@ -19,3 +19,5 @@
|
||||
- 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.
|
||||
- Remove the outer gray trace card and tinted list shell, using a flat tab layout with toolbar/table separators in both themes.
|
||||
- Top hint and separate recording switch → a toolbar Start/Pause/Connecting button. Filters, recording, and refresh share gray icon-and-text styling and 36px input height; search fills available width and includes the message count in its placeholder.
|
||||
- Use Vuetify datatable for the trace list, retaining fixed headers, stable trace row IDs, and explicit detail selection without adding pagination or automatic selection.
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
LoaderCircle,
|
||||
Pause,
|
||||
Play,
|
||||
PlugZap,
|
||||
RefreshCw,
|
||||
Search,
|
||||
@@ -21,6 +24,11 @@ import defaultPluginIcon from "/favicon.svg";
|
||||
|
||||
const { tm } = useModuleI18n("features/trace");
|
||||
const { tm: configTm } = useModuleI18n("features/config");
|
||||
const props = defineProps({
|
||||
recordingEnabled: { type: Boolean, default: true },
|
||||
recordingBusy: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(["toggle-recording"]);
|
||||
const maxItems = 300;
|
||||
const events = ref([]);
|
||||
const selectedSpanId = ref(null);
|
||||
@@ -31,6 +39,7 @@ const loading = ref(true);
|
||||
const clockNow = ref(Date.now() / 1000);
|
||||
const durationTimer = ref(null);
|
||||
const connected = ref(false);
|
||||
const recordingLabel = computed(() => !props.recordingEnabled ? tm("controls.start") : connected.value ? tm("controls.pauseButton") : tm("connecting"));
|
||||
const eventSource = ref(null);
|
||||
const retryTimer = ref(null);
|
||||
const retryAttempts = ref(0);
|
||||
@@ -125,6 +134,17 @@ const traceRows = computed(() => filteredEvents.value.map((event) => ({
|
||||
spans: timelineRecords(event).length,
|
||||
})));
|
||||
|
||||
const traceHeaders = computed(() => [
|
||||
{ title: tm("columns.status"), key: "status", width: 100 },
|
||||
{ title: tm("columns.platform"), key: "platform", width: 56 },
|
||||
{ title: tm("columns.timestamp"), key: "timestamp", width: 132 },
|
||||
{ title: tm("columns.duration"), key: "duration", width: 92 },
|
||||
{ title: tm("columns.message"), key: "message", width: "26%" },
|
||||
{ title: "UMO", key: "umo", width: "28%" },
|
||||
{ title: tm("columns.sender"), key: "sender", width: 110 },
|
||||
{ title: "Spans", key: "spans", width: 64 },
|
||||
]);
|
||||
|
||||
const selectedStats = computed(() => {
|
||||
const event = selectedEvent.value;
|
||||
if (!event) return { duration: "-", handlers: 0, responses: 0 };
|
||||
@@ -509,7 +529,7 @@ onBeforeUnmount(() => {
|
||||
<v-btn
|
||||
class="filter-toggle-btn"
|
||||
size="small"
|
||||
:variant="filtersOpen || selectedFilters.length ? 'tonal' : 'text'"
|
||||
variant="tonal"
|
||||
:aria-expanded="filtersOpen"
|
||||
aria-controls="trace-filter-panel"
|
||||
@click="filtersOpen = !filtersOpen"
|
||||
@@ -520,16 +540,29 @@ onBeforeUnmount(() => {
|
||||
</v-btn>
|
||||
<div class="trace-search">
|
||||
<Search :size="16" aria-hidden="true" />
|
||||
<input v-model="searchText" :placeholder="tm('search')" />
|
||||
<input v-model="searchText" :placeholder="tm('search', { count: filteredEvents.length })" />
|
||||
<button v-if="searchText" type="button" class="icon-button" :aria-label="tm('clearSearch')" @click="clearSearch">
|
||||
<X :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="trace-live-state" :class="{ connected }"><span class="live-dot" />{{ connected ? tm("live") : tm("connecting") }}</div>
|
||||
<div class="trace-count">{{ filteredEvents.length }} {{ tm("records") }}</div>
|
||||
<button type="button" class="icon-button refresh-button" :aria-label="tm('refresh')" @click="fetchTraceHistory">
|
||||
<v-btn
|
||||
class="trace-recording-btn"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:loading="recordingBusy"
|
||||
:disabled="recordingBusy"
|
||||
:aria-pressed="recordingEnabled"
|
||||
:aria-label="tm(recordingEnabled ? 'controls.pause' : 'controls.resume')"
|
||||
:title="tm(recordingEnabled ? 'controls.pause' : 'controls.resume')"
|
||||
@click="emit('toggle-recording')"
|
||||
>
|
||||
<component :is="!recordingEnabled ? Play : connected ? Pause : LoaderCircle" :size="16" :class="{ 'trace-connecting-icon': recordingEnabled && !connected }" aria-hidden="true" />
|
||||
{{ recordingLabel }}
|
||||
</v-btn>
|
||||
<v-btn class="refresh-button" size="small" variant="tonal" :aria-label="tm('refresh')" :title="tm('refresh')" @click="fetchTraceHistory">
|
||||
<RefreshCw :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<span>{{ tm("refresh") }}</span>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="trace-workspace">
|
||||
@@ -552,22 +585,23 @@ onBeforeUnmount(() => {
|
||||
<section class="trace-list" :aria-label="tm('traceList')">
|
||||
<div v-if="loading" class="trace-empty-state">{{ tm("loading") }}</div>
|
||||
<div v-else-if="filteredEvents.length === 0" class="trace-empty-state"><Activity :size="22" aria-hidden="true" /><span>{{ tm("empty") }}</span></div>
|
||||
<table v-else class="trace-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ tm("columns.status") }}</th>
|
||||
<th scope="col">{{ tm("columns.platform") }}</th>
|
||||
<th scope="col">{{ tm("columns.timestamp") }}</th>
|
||||
<th scope="col">{{ tm("columns.duration") }}</th>
|
||||
<th scope="col">{{ tm("columns.message") }}</th>
|
||||
<th scope="col">UMO</th>
|
||||
<th scope="col">{{ tm("columns.sender") }}</th>
|
||||
<th scope="col" :title="tm('columns.spansHint')">Spans</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<v-data-table
|
||||
v-else
|
||||
class="trace-list-table"
|
||||
:headers="traceHeaders"
|
||||
:items="traceRows"
|
||||
item-value="event.span_id"
|
||||
:items-per-page="-1"
|
||||
:mobile="false"
|
||||
density="compact"
|
||||
fixed-header
|
||||
height="100%"
|
||||
hide-default-footer
|
||||
disable-sort
|
||||
>
|
||||
<template #header.spans><span :title="tm('columns.spansHint')">Spans</span></template>
|
||||
<template #item="{ item: { event, duration, spans } }">
|
||||
<tr
|
||||
v-for="{ event, duration, spans } in traceRows"
|
||||
:key="event.span_id"
|
||||
class="trace-list-row"
|
||||
:class="{ selected: event.span_id === selectedSpanId }"
|
||||
@@ -597,8 +631,8 @@ onBeforeUnmount(() => {
|
||||
<td class="trace-sender-cell" :title="event.sender_name">{{ event.sender_name || "-" }}</td>
|
||||
<td class="trace-spans-cell" :title="tm('columns.spansHint')">{{ spans }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</section>
|
||||
|
||||
<main v-if="selectedEvent" class="trace-detail" @keydown.esc.stop="selectEvent(null)">
|
||||
@@ -742,10 +776,12 @@ onBeforeUnmount(() => {
|
||||
.timeline-group-expansion svg { transition: transform 0.15s ease; }
|
||||
.timeline-group-expansion .is-collapsed { transform: rotate(-90deg); }
|
||||
.timeline-nested .timeline-content { border-left: 2px solid var(--trace-border); padding-left: 10px; }
|
||||
.trace-explorer { --trace-border: rgba(var(--v-theme-on-surface), 0.1); --trace-muted: rgba(var(--v-theme-on-surface), 0.58); background: transparent; border: 0; border-radius: 0; display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden; }
|
||||
.trace-explorer { --trace-border: rgba(var(--v-theme-on-surface), 0.1); --trace-muted: rgba(var(--v-theme-on-surface), 0.58); --trace-control-background: rgba(var(--v-theme-on-surface), 0.06); background: transparent; border: 0; border-radius: 0; display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden; }
|
||||
.trace-explorer-toolbar { align-items: center; border-bottom: 1px solid var(--trace-border); display: flex; gap: 10px; min-height: 50px; padding: 8px 12px; }
|
||||
.trace-explorer-toolbar :deep(.v-btn) { background: var(--trace-control-background); height: 36px; }
|
||||
.trace-explorer-toolbar :deep(.v-btn__underlay) { display: none; }
|
||||
.filter-toggle-btn { flex: 0 0 auto; }
|
||||
.filter-toggle-btn :deep(.v-btn__content) { gap: 5px; }
|
||||
.trace-explorer-toolbar :deep(.v-btn__content) { gap: 6px; }
|
||||
.filter-status-dot { background: rgb(var(--v-theme-primary)); border-radius: 50%; height: 6px; width: 6px; }
|
||||
.trace-workspace { display: flex; flex: 1; min-height: 0; min-width: 0; overflow: hidden; position: relative; }
|
||||
.trace-filter-panel { background: rgb(var(--v-theme-surface)); border-right: 1px solid var(--trace-border); flex: 0 0 220px; min-height: 0; overflow: auto; padding: 12px; }
|
||||
@@ -756,22 +792,23 @@ onBeforeUnmount(() => {
|
||||
.trace-filter-option :deep(.v-selection-control) { flex: 0 0 auto; min-height: 32px; }
|
||||
.trace-filter-hint { color: var(--trace-muted); font-size: 11px; line-height: 1.6; margin: 12px 0 0; }
|
||||
.trace-filter-backdrop { display: none; }
|
||||
.trace-search { align-items: center; background: rgba(var(--v-theme-on-surface), 0.06); border: 1px solid transparent; border-radius: 7px; color: var(--trace-muted); display: flex; flex: 1; gap: 8px; max-width: 520px; padding: 7px 9px; }
|
||||
.trace-search { align-items: center; background: var(--trace-control-background); border: 1px solid transparent; border-radius: 7px; color: var(--trace-muted); display: flex; flex: 1; gap: 8px; height: 36px; min-width: 0; padding: 0 10px; }
|
||||
.trace-search:focus-within { border-color: rgba(var(--v-theme-primary), 0.6); }
|
||||
.trace-search input { background: transparent; border: 0; color: rgb(var(--v-theme-on-surface)); font-size: 12px; min-width: 0; outline: 0; width: 100%; }
|
||||
.icon-button { align-items: center; background: transparent; border: 0; border-radius: 5px; color: var(--trace-muted); cursor: pointer; display: inline-flex; justify-content: center; padding: 4px; }
|
||||
.icon-button:hover { background: rgba(var(--v-theme-on-surface), 0.08); color: rgb(var(--v-theme-on-surface)); }
|
||||
.trace-live-state, .trace-count { color: var(--trace-muted); font-size: 11px; white-space: nowrap; }
|
||||
.trace-live-state { align-items: center; display: flex; gap: 6px; }
|
||||
.live-dot { background: #a1a1aa; border-radius: 50%; height: 6px; width: 6px; }
|
||||
.trace-live-state.connected .live-dot { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.14); }
|
||||
.trace-recording-btn { flex: 0 0 auto; white-space: nowrap; }
|
||||
.refresh-button { flex: 0 0 auto; white-space: nowrap; }
|
||||
.trace-connecting-icon { animation: trace-connecting-spin 1s linear infinite; }
|
||||
@keyframes trace-connecting-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .trace-connecting-icon { animation: none; } }
|
||||
.trace-explorer-body { display: flex; flex: 1; min-height: 0; min-width: 0; overflow: hidden; position: relative; }
|
||||
.trace-list { flex: 1; min-height: 0; min-width: 0; overflow: auto; }
|
||||
.trace-list { flex: 1; min-height: 0; min-width: 0; overflow: hidden; }
|
||||
.has-detail .trace-list { border-right: 1px solid var(--trace-border); }
|
||||
.trace-list-table { border-collapse: separate; border-spacing: 0; font-size: 12px; min-width: 1100px; table-layout: fixed; width: 100%; }
|
||||
.trace-list-table th { background: rgb(var(--v-theme-surface)); border-bottom: 1px solid var(--trace-border); color: var(--trace-muted); font-size: 11px; font-weight: 600; padding: 11px 12px; position: sticky; text-align: left; top: 0; white-space: nowrap; z-index: 1; }
|
||||
.trace-list-table th:nth-child(1) { width: 100px; } .trace-list-table th:nth-child(2) { width: 56px; } .trace-list-table th:nth-child(3) { width: 132px; } .trace-list-table th:nth-child(4) { width: 92px; } .trace-list-table th:nth-child(5) { width: 26%; } .trace-list-table th:nth-child(6) { width: 28%; } .trace-list-table th:nth-child(7) { width: 110px; } .trace-list-table th:nth-child(8) { width: 64px; }
|
||||
.trace-list-table td { border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.06); height: 48px; overflow: hidden; padding: 10px 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.trace-list-table { background: transparent; border-radius: 0; font-size: 12px; height: 100%; }
|
||||
.trace-list-table :deep(table) { border-collapse: separate; border-spacing: 0; min-width: 1100px; table-layout: fixed; width: 100%; }
|
||||
.trace-list-table :deep(table > thead > tr > th) { background: rgb(var(--v-theme-surface)); border-bottom: 1px solid var(--trace-border); color: var(--trace-muted); font-size: 11px; font-weight: 600; padding: 11px 12px; white-space: nowrap; }
|
||||
.trace-list-table :deep(table > tbody > tr > td) { border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.06); height: 48px; overflow: hidden; padding: 10px 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.trace-list-row { cursor: pointer; } .trace-list-row:hover { background: rgba(var(--v-theme-on-surface), 0.045); } .trace-list-row.selected { background: rgba(var(--v-theme-primary), 0.1); } .trace-list-row:focus-visible { outline: 2px solid rgb(var(--v-theme-primary)); outline-offset: -2px; }
|
||||
.trace-item-platform { align-items: center; color: var(--trace-muted); display: inline-flex; height: 20px; justify-content: center; vertical-align: middle; width: 20px; }
|
||||
.trace-item-platform img { height: 20px; object-fit: contain; width: 20px; }
|
||||
@@ -792,6 +829,6 @@ onBeforeUnmount(() => {
|
||||
.timeline-fields { margin-top: 6px; } .timeline-fields summary { color: var(--trace-muted); cursor: pointer; font-size: 10px; } .timeline-fields pre { background: rgba(var(--v-theme-on-surface), 0.04); border-radius: 6px; color: var(--trace-muted); font-size: 10px; line-height: 1.5; margin: 6px 0 0; max-height: 280px; overflow: auto; padding: 8px; white-space: pre-wrap; word-break: break-word; }
|
||||
.trace-empty-state { align-items: center; color: var(--trace-muted); display: flex; flex-direction: column; font-size: 12px; gap: 8px; justify-content: center; min-height: 180px; }
|
||||
@media (max-width: 1100px) { .trace-detail { border-left: 1px solid var(--trace-border); bottom: 0; box-shadow: -8px 0 24px rgba(0, 0, 0, 0.12); max-width: none; min-width: 0; position: absolute; right: 0; top: 0; width: min(760px, 90%); z-index: 1; } }
|
||||
@media (max-width: 900px) { .trace-filter-panel { bottom: 0; box-shadow: 8px 0 24px rgba(0, 0, 0, 0.18); left: 0; max-width: 85%; position: absolute; top: 0; width: 220px; z-index: 3; } .trace-filter-backdrop { background: rgba(var(--v-theme-on-surface), 0.12); border: 0; display: block; inset: 0; position: absolute; z-index: 2; } .trace-detail { padding: 15px; } .trace-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .trace-count { display: none; } }
|
||||
@media (max-width: 640px) { .trace-explorer-toolbar { flex-wrap: wrap; } .filter-toggle-btn { order: 1; } .trace-search { flex: 1 1 160px; max-width: none; order: 1; } .trace-live-state { order: 2; } .refresh-button { margin-left: auto; order: 2; } .trace-detail { width: 100%; } .trace-detail-time { display: none; } }
|
||||
@media (max-width: 900px) { .trace-filter-panel { bottom: 0; box-shadow: 8px 0 24px rgba(0, 0, 0, 0.18); left: 0; max-width: 85%; position: absolute; top: 0; width: 220px; z-index: 3; } .trace-filter-backdrop { background: rgba(var(--v-theme-on-surface), 0.12); border: 0; display: block; inset: 0; position: absolute; z-index: 2; } .trace-detail { padding: 15px; } .trace-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 640px) { .trace-explorer-toolbar { flex-wrap: wrap; } .filter-toggle-btn { order: 1; } .trace-search { flex: 1 1 160px; max-width: none; order: 1; } .trace-recording-btn { order: 2; } .refresh-button { margin-left: auto; order: 2; } .trace-detail { width: 100%; } .trace-detail-time { display: none; } }
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"collapse": "Collapse",
|
||||
"showMore": "Show more",
|
||||
"empty": "No trace data yet",
|
||||
"search": "Search messages, sessions, or plugins",
|
||||
"search": "Search messages, sessions, or plugins in {count} records",
|
||||
"clearSearch": "Clear search",
|
||||
"live": "Live",
|
||||
"connecting": "Connecting",
|
||||
@@ -34,6 +34,14 @@
|
||||
"details": "View details",
|
||||
"selectTrace": "Select a trace to inspect the complete execution chain",
|
||||
"closeDetails": "Close details",
|
||||
"controls": {
|
||||
"start": "Start",
|
||||
"pauseButton": "Pause",
|
||||
"pause": "Pause trace recording",
|
||||
"resume": "Start trace recording",
|
||||
"loadFailed": "Failed to load trace settings. Refresh to retry.",
|
||||
"updateFailed": "Failed to change trace recording. Please retry."
|
||||
},
|
||||
"columns": {
|
||||
"duration": "Duration",
|
||||
"status": "Status",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"collapse": "折りたたむ",
|
||||
"showMore": "さらに表示",
|
||||
"empty": "トレースデータはありません",
|
||||
"search": "メッセージ、セッション、プラグインを検索",
|
||||
"search": "{count} 件の記録からメッセージ、セッション、プラグインを検索",
|
||||
"clearSearch": "検索をクリア",
|
||||
"live": "ライブ",
|
||||
"connecting": "接続中",
|
||||
@@ -34,6 +34,14 @@
|
||||
"details": "詳細を表示",
|
||||
"selectTrace": "トレースを選択すると実行チェーンを確認できます",
|
||||
"closeDetails": "詳細を閉じる",
|
||||
"controls": {
|
||||
"start": "開始",
|
||||
"pauseButton": "一時停止",
|
||||
"pause": "トレース記録を一時停止",
|
||||
"resume": "トレース記録を開始",
|
||||
"loadFailed": "トレース設定の取得に失敗しました。ページを更新してください。",
|
||||
"updateFailed": "トレース記録の切り替えに失敗しました。再試行してください。"
|
||||
},
|
||||
"columns": {
|
||||
"duration": "実行時間",
|
||||
"status": "状態",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"collapse": "Свернуть",
|
||||
"showMore": "Показать ещё",
|
||||
"empty": "Данных трассировки пока нет",
|
||||
"search": "Поиск сообщений, сессий или плагинов",
|
||||
"search": "Поиск сообщений, сессий и плагинов в {count} записях",
|
||||
"clearSearch": "Очистить поиск",
|
||||
"live": "В эфире",
|
||||
"connecting": "Подключение",
|
||||
@@ -34,6 +34,14 @@
|
||||
"details": "Подробности",
|
||||
"selectTrace": "Выберите трассировку, чтобы увидеть цепочку выполнения",
|
||||
"closeDetails": "Закрыть подробности",
|
||||
"controls": {
|
||||
"start": "Запустить",
|
||||
"pauseButton": "Пауза",
|
||||
"pause": "Приостановить запись трассировок",
|
||||
"resume": "Начать запись трассировок",
|
||||
"loadFailed": "Не удалось загрузить настройки трассировки. Обновите страницу.",
|
||||
"updateFailed": "Не удалось изменить запись трассировок. Повторите попытку."
|
||||
},
|
||||
"columns": {
|
||||
"duration": "Длительность",
|
||||
"status": "Статус",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"collapse": "收起",
|
||||
"showMore": "显示更多",
|
||||
"empty": "暂无追踪数据",
|
||||
"search": "搜索消息、会话、插件",
|
||||
"search": "在 {count} 条记录中搜索消息、会话、插件",
|
||||
"clearSearch": "清除搜索",
|
||||
"live": "实时",
|
||||
"connecting": "连接中",
|
||||
@@ -34,6 +34,14 @@
|
||||
"details": "查看详情",
|
||||
"selectTrace": "选择一条追踪查看完整执行链",
|
||||
"closeDetails": "关闭详情",
|
||||
"controls": {
|
||||
"start": "启动",
|
||||
"pauseButton": "暂停",
|
||||
"pause": "暂停追踪记录",
|
||||
"resume": "开始追踪记录",
|
||||
"loadFailed": "读取追踪设置失败,请刷新页面重试",
|
||||
"updateFailed": "切换追踪记录失败,请重试"
|
||||
},
|
||||
"columns": {
|
||||
"duration": "运行时间",
|
||||
"status": "状态",
|
||||
|
||||
@@ -1,34 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { Waypoints } from "@lucide/vue";
|
||||
import TraceDisplayer from "@/components/shared/TraceDisplayer.vue";
|
||||
import { traceApi } from "@/api/v1";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { useToast } from "@/utils/toast";
|
||||
|
||||
const { tm } = useModuleI18n("features/trace");
|
||||
const toast = useToast();
|
||||
const traceEnabled = ref(true);
|
||||
const loading = ref(false);
|
||||
const traceDisplayerKey = ref(0);
|
||||
const loading = ref(true);
|
||||
|
||||
async function fetchTraceSettings() {
|
||||
try {
|
||||
const response = await traceApi.getSettings();
|
||||
if (response.data?.status === "ok") {
|
||||
const enabled = response.data.data?.trace_enable;
|
||||
traceEnabled.value = typeof enabled === "boolean" ? enabled : true;
|
||||
}
|
||||
if (response.data?.status !== "ok")
|
||||
throw new Error("Trace settings are unavailable");
|
||||
const enabled = response.data.data?.trace_enable;
|
||||
traceEnabled.value = typeof enabled === "boolean" ? enabled : true;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch trace settings:", error);
|
||||
toast.error(tm("controls.loadFailed"));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTraceSettings() {
|
||||
if (loading.value) return;
|
||||
const nextEnabled = !traceEnabled.value;
|
||||
loading.value = true;
|
||||
try {
|
||||
await traceApi.updateSettings({ trace_enable: traceEnabled.value });
|
||||
traceDisplayerKey.value += 1;
|
||||
const response = await traceApi.updateSettings({
|
||||
trace_enable: nextEnabled,
|
||||
});
|
||||
if (response.data?.status !== "ok")
|
||||
throw new Error("Trace settings update failed");
|
||||
traceEnabled.value = nextEnabled;
|
||||
} catch (error) {
|
||||
console.error("Failed to update trace settings:", error);
|
||||
toast.error(tm("controls.updateFailed"));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -39,31 +49,12 @@ onMounted(fetchTraceSettings);
|
||||
|
||||
<template>
|
||||
<div class="trace-page">
|
||||
<div class="trace-toolbar">
|
||||
<div class="trace-hint">
|
||||
<Waypoints :size="16" :stroke-width="1.8" aria-hidden="true" />
|
||||
<span>{{ tm("hint") }}</span>
|
||||
</div>
|
||||
<v-switch
|
||||
v-model="traceEnabled"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
:aria-label="tm('toggleLabel')"
|
||||
color="primary"
|
||||
hide-details
|
||||
density="compact"
|
||||
inset
|
||||
@update:model-value="updateTraceSettings"
|
||||
>
|
||||
<template #label>
|
||||
<span class="switch-label">
|
||||
{{ traceEnabled ? tm("recording") : tm("paused") }}
|
||||
</span>
|
||||
</template>
|
||||
</v-switch>
|
||||
</div>
|
||||
<div class="trace-body">
|
||||
<TraceDisplayer :key="traceDisplayerKey" />
|
||||
<TraceDisplayer
|
||||
:recording-enabled="traceEnabled"
|
||||
:recording-busy="loading"
|
||||
@toggle-recording="updateTraceSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -81,40 +72,6 @@ onMounted(fetchTraceSettings);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trace-toolbar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 20px;
|
||||
justify-content: space-between;
|
||||
min-height: 42px;
|
||||
padding: 0 2px 10px;
|
||||
}
|
||||
|
||||
.trace-hint {
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), 0.58);
|
||||
display: flex;
|
||||
font-size: 0.75rem;
|
||||
gap: 7px;
|
||||
line-height: 1.45;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trace-hint svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trace-toolbar :deep(.v-switch .v-selection-control) {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.trace-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -124,25 +81,5 @@ onMounted(fetchTraceSettings);
|
||||
.trace-page {
|
||||
padding: 0 4px 6px;
|
||||
}
|
||||
|
||||
.trace-toolbar {
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.trace-hint {
|
||||
padding-top: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.trace-toolbar {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.trace-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,13 +13,13 @@ const ast = ts.createSourceFile("trace.js", descriptor.scriptSetup.content, ts.S
|
||||
function setup(t) {
|
||||
const focused = [];
|
||||
const context = vm.createContext({
|
||||
computed, nextTick, reactive, ref, watch,
|
||||
computed, nextTick, reactive, ref, watch, tm: (key) => key,
|
||||
getPlatformIcon: (name) => name + ".png",
|
||||
document: { querySelector: (selector) => ({ scrollIntoView() {}, focus() { focused.push(selector); } }) },
|
||||
});
|
||||
const names = new Set([
|
||||
"events", "selectedSpanId", "searchText", "selectedFilters", "umoNames", "clockNow",
|
||||
"eventIndex", "maxItems", "selectedEvent", "normalizedSearch", "filteredEvents", "traceRows",
|
||||
"eventIndex", "maxItems", "selectedEvent", "normalizedSearch", "filteredEvents", "traceRows", "traceHeaders",
|
||||
"processNewTraces", "matchesFilter", "recordSearchText", "timelineRecords", "formatDuration", "selectEvent",
|
||||
]);
|
||||
const statements = ast.statements.filter((node) =>
|
||||
@@ -30,7 +30,7 @@ function setup(t) {
|
||||
vm.runInContext(statements.map((node) => node.getText(ast)).join("\n"), context);
|
||||
const watcher = ast.statements.find((node) => node.getText(ast).startsWith("watch(filteredEvents,"));
|
||||
t.after(vm.runInContext(watcher.getText(ast), context));
|
||||
return { focused, ...vm.runInContext("({ events, selectedSpanId, selectedEvent, selectedFilters, traceRows, clockNow, processNewTraces, selectEvent })", context) };
|
||||
return { focused, ...vm.runInContext("({ events, selectedSpanId, selectedEvent, selectedFilters, traceRows, traceHeaders, clockNow, processNewTraces, selectEvent })", context) };
|
||||
}
|
||||
|
||||
function record(span, action, time, fields = {}) {
|
||||
@@ -108,13 +108,17 @@ test("duration updates while running or errored-but-active and freezes on comple
|
||||
assert.equal(state.traceRows.value[0].event.status, "error");
|
||||
});
|
||||
|
||||
test("list columns follow the requested order and the inspector has an explicit close action", () => {
|
||||
const header = descriptor.template.content.match(/<thead>([\s\S]*?)<\/thead>/)[1];
|
||||
const columns = Array.from(header.matchAll(/<th\b[^>]*>(.*?)<\/th>/gs), (match) => match[1]);
|
||||
assert.deepEqual(columns, [
|
||||
'{{ tm("columns.status") }}', '{{ tm("columns.platform") }}', '{{ tm("columns.timestamp") }}',
|
||||
'{{ tm("columns.duration") }}', '{{ tm("columns.message") }}', "UMO", '{{ tm("columns.sender") }}', "Spans",
|
||||
test("Vuetify datatable keeps the requested columns and explicit detail selection", (t) => {
|
||||
const state = setup(t);
|
||||
assert.deepEqual(Array.from(state.traceHeaders.value, (header) => header.title), [
|
||||
"columns.status", "columns.platform", "columns.timestamp",
|
||||
"columns.duration", "columns.message", "UMO", "columns.sender", "Spans",
|
||||
]);
|
||||
assert.match(descriptor.template.content, /<v-data-table/);
|
||||
assert.match(descriptor.template.content, /item-value="event.span_id"/);
|
||||
assert.match(descriptor.template.content, /:items-per-page="-1"/);
|
||||
assert.match(descriptor.template.content, /hide-default-footer/);
|
||||
assert.match(descriptor.template.content, /#item="\{ item: \{ event, duration, spans \} \}"/);
|
||||
assert.match(descriptor.template.content, /<main v-if="selectedEvent"/);
|
||||
assert.match(descriptor.template.content, /tm\('closeDetails'\)/);
|
||||
assert.match(descriptor.template.content, /@keydown.enter="selectEvent\(event.span_id\)"/);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { parse } from "vue/compiler-sfc";
|
||||
|
||||
const page = parse(readFileSync(new URL("../src/views/TracePage.vue", import.meta.url), "utf8")).descriptor;
|
||||
const view = parse(readFileSync(new URL("../src/components/shared/TraceDisplayer.vue", import.meta.url), "utf8")).descriptor;
|
||||
|
||||
function setup(response = { data: { status: "ok", data: { trace_enable: true } } }) {
|
||||
const requests = [];
|
||||
const errors = [];
|
||||
const ast = ts.createSourceFile("trace.ts", page.scriptSetup.content, ts.ScriptTarget.Latest, true);
|
||||
const names = new Set(["traceEnabled", "loading", "fetchTraceSettings", "updateTraceSettings"]);
|
||||
const statements = ast.statements.filter((node) =>
|
||||
ts.isFunctionDeclaration(node) ? names.has(node.name?.text) :
|
||||
ts.isVariableStatement(node) && names.has(node.declarationList.declarations[0]?.name.getText(ast)),
|
||||
);
|
||||
assert.equal(statements.length, names.size);
|
||||
const context = vm.createContext({
|
||||
ref, tm: (key) => key, toast: { error: (message) => errors.push(message) }, console: { error() {} },
|
||||
traceApi: {
|
||||
getSettings: async () => response,
|
||||
updateSettings: (payload) => new Promise((resolve, reject) => requests.push({ payload, resolve, reject })),
|
||||
},
|
||||
});
|
||||
vm.runInContext(ts.transpile(statements.map((node) => node.getText(ast)).join("\n")), context);
|
||||
return { requests, errors, ...vm.runInContext("({ traceEnabled, loading, fetchTraceSettings, updateTraceSettings })", context) };
|
||||
}
|
||||
|
||||
test("settings load before toggling and respect backend-disabled recording", async () => {
|
||||
const state = setup({ data: { status: "ok", data: { trace_enable: false } } });
|
||||
assert.equal(state.loading.value, true);
|
||||
await state.updateTraceSettings();
|
||||
assert.equal(state.requests.length, 0);
|
||||
await state.fetchTraceSettings();
|
||||
assert.equal(state.traceEnabled.value, false);
|
||||
assert.equal(state.loading.value, false);
|
||||
});
|
||||
|
||||
test("toggle changes state only after confirmation and rejects repeated clicks while pending", async () => {
|
||||
const state = setup();
|
||||
await state.fetchTraceSettings();
|
||||
const pause = state.updateTraceSettings();
|
||||
assert.equal(state.loading.value, true);
|
||||
assert.equal(state.traceEnabled.value, true);
|
||||
assert.equal(state.requests[0].payload.trace_enable, false);
|
||||
await state.updateTraceSettings();
|
||||
assert.equal(state.requests.length, 1);
|
||||
state.requests[0].resolve({ data: { status: "ok" } });
|
||||
await pause;
|
||||
assert.equal(state.traceEnabled.value, false);
|
||||
assert.equal(state.loading.value, false);
|
||||
const resume = state.updateTraceSettings();
|
||||
assert.equal(state.requests[1].payload.trace_enable, true);
|
||||
state.requests[1].resolve({ data: { status: "ok" } });
|
||||
await resume;
|
||||
assert.equal(state.traceEnabled.value, true);
|
||||
assert.doesNotMatch(page.template.content, /traceDisplayerKey|:key=|v-switch|trace-hint/);
|
||||
});
|
||||
|
||||
test("business and network failures keep the previous state and report an error", async () => {
|
||||
const state = setup();
|
||||
await state.fetchTraceSettings();
|
||||
let pending = state.updateTraceSettings();
|
||||
state.requests[0].resolve({ data: { status: "error" } });
|
||||
await pending;
|
||||
assert.equal(state.traceEnabled.value, true);
|
||||
assert.equal(state.loading.value, false);
|
||||
pending = state.updateTraceSettings();
|
||||
state.requests[1].reject(new Error("offline"));
|
||||
await pending;
|
||||
assert.equal(state.traceEnabled.value, true);
|
||||
assert.equal(state.loading.value, false);
|
||||
assert.deepEqual(state.errors, ["controls.updateFailed", "controls.updateFailed"]);
|
||||
});
|
||||
|
||||
test("failed settings loading unlocks the control and gives feedback", async () => {
|
||||
const state = setup({ data: { status: "error" } });
|
||||
await state.fetchTraceSettings();
|
||||
assert.equal(state.loading.value, false);
|
||||
assert.deepEqual(state.errors, ["controls.loadFailed"]);
|
||||
});
|
||||
|
||||
test("recording button reads Start, Pause, or Connecting without a status dot", () => {
|
||||
const ast = ts.createSourceFile("trace.js", view.scriptSetup.content, ts.ScriptTarget.Latest, true);
|
||||
const definition = ast.statements.find((node) => ts.isVariableStatement(node) &&
|
||||
node.declarationList.declarations[0]?.name.getText(ast) === "recordingLabel");
|
||||
const props = reactive({ recordingEnabled: true });
|
||||
const connected = ref(false);
|
||||
const labels = { "controls.start": "启动", "controls.pauseButton": "暂停", connecting: "连接中" };
|
||||
const context = vm.createContext({ props, connected, computed, tm: (key) => labels[key] });
|
||||
vm.runInContext(definition.getText(ast), context);
|
||||
const label = vm.runInContext("recordingLabel", context);
|
||||
assert.equal(label.value, "连接中");
|
||||
connected.value = true;
|
||||
assert.equal(label.value, "暂停");
|
||||
props.recordingEnabled = false;
|
||||
assert.equal(label.value, "启动");
|
||||
connected.value = false;
|
||||
assert.equal(label.value, "启动");
|
||||
assert.doesNotMatch(view.template.content, /live-dot|trace-count/);
|
||||
});
|
||||
|
||||
test("toolbar has matching icon-and-text buttons, fluid input, and an embedded record count", () => {
|
||||
const toolbar = view.template.content.split('<div class="trace-workspace">')[0];
|
||||
const buttons = Array.from(toolbar.matchAll(/<v-btn\b[\s\S]*?<\/v-btn>/g), (match) => match[0]);
|
||||
assert.equal(buttons.length, 3);
|
||||
for (const button of buttons) assert.match(button, /variant="tonal"/);
|
||||
assert.match(buttons[0], /<v-icon/);
|
||||
assert.match(buttons[0], /tm\("filters.button"\)/);
|
||||
assert.match(buttons[1], /<component/);
|
||||
assert.match(buttons[1], /recordingLabel/);
|
||||
assert.match(buttons[2], /<RefreshCw/);
|
||||
assert.match(buttons[2], /tm\("refresh"\)/);
|
||||
assert.match(buttons[1], /:loading="recordingBusy"/);
|
||||
assert.match(buttons[1], /:disabled="recordingBusy"/);
|
||||
assert.match(toolbar, /tm\('search', \{ count: filteredEvents.length \}\)/);
|
||||
const searchStyle = view.styles[0].content.match(/\.trace-search\s*\{([^}]+)\}/)[1];
|
||||
assert.match(searchStyle, /flex:\s*1/);
|
||||
assert.match(searchStyle, /height:\s*36px/);
|
||||
assert.doesNotMatch(searchStyle, /max-width/);
|
||||
const buttonStyle = view.styles[0].content.match(/\.trace-explorer-toolbar :deep\(\.v-btn\) \{([^}]+)\}/)[1];
|
||||
assert.match(buttonStyle, /height:\s*36px/);
|
||||
assert.match(buttonStyle, /background:\s*var\(--trace-control-background\)/);
|
||||
assert.match(searchStyle, /background:\s*var\(--trace-control-background\)/);
|
||||
assert.match(view.styles[0].content, /\.v-btn__underlay\) \{ display: none; \}/);
|
||||
});
|
||||
@@ -172,9 +172,9 @@ The toolbar contains search, fullscreen, font-size controls, and `Install Pip pa
|
||||
|
||||
### Trace
|
||||
|
||||
The trace tab has no outer gray card: its hint bar, toolbar, and list sit directly on the page with only the necessary separators, in both light and dark themes.
|
||||
The trace tab has no outer gray card; its toolbar and list sit directly on the page with only the necessary separators. The former hint and switch are replaced by a toolbar button showing Pause (enabled and connected), Connecting (enabled but disconnected), or Start (disabled). Filters, recording, and refresh use matching light-gray icon-and-text buttons sharing the input's background color, all 36px high. Search fills the remaining width, pushing actions to the right. Its placeholder includes the message count instead of a separate counter. Click to pause or resume. Updates show loading and prevent repeated clicks; failures preserve the previous state and display an error. Pausing retains the list and open details in both themes.
|
||||
|
||||
The `Trace` tab shows message execution chains: receipt, plugin handlers, Agent requests, responses, and completion. It opens as a full-width tracing list with columns ordered as status chip, platform logo, timestamp, duration, message, UMO, sender, and Spans. Duration updates while execution is active. Click a row to open its timeline details on the right; use the close button or Escape to return to the list. History loading, incoming records, and filtering never automatically open another trace. Scroll horizontally on narrow screens. Spans counts timeline nodes after pairing start/end records; collapsing groups does not reduce it. The toolbar record count still counts messages only. Open `Filters` next to search to select `Has errors`, `Has model calls`, and `Has plugin calls`. Multiple selections require all conditions and can be combined with search. No selection shows all traces; `Reset` clears selections, and closing the sidebar preserves them. This sidebar replaces the former `All / Errors / Agent / Plugins` single-select tabs. Use the top switch to enable or disable trace recording.
|
||||
The `Trace` tab shows message execution chains: receipt, plugin handlers, Agent requests, responses, and completion. It opens as a full-width Vuetify datatable with columns ordered as status chip, platform logo, timestamp, duration, message, UMO, sender, and Spans. Duration updates while execution is active. Click a row to open its timeline details on the right; use the close button or Escape to return to the list. History loading, incoming records, and filtering never automatically open another trace. Scroll horizontally on narrow screens. Spans counts timeline nodes after pairing start/end records; collapsing groups does not reduce it. The search placeholder counts message traces only. Open `Filters` next to search to select `Has errors`, `Has model calls`, and `Has plugin calls`. Multiple selections require all conditions and can be combined with search. No selection shows all traces; `Reset` clears selections, and closing the sidebar preserves them. This sidebar replaces the former `All / Errors / Agent / Plugins` single-select tabs. Use the toolbar recording button to enable or disable trace recording.
|
||||
|
||||
Session aliases replace raw UMOs when available; hover over the dotted underline to see the full UMO. Plugins use their display name when available; hover to see the plugin ID. Handler names, event types, and duration are also shown. Plugin handler nodes use the plugin logo when available and the plugin card's default blue star icon if it is missing or fails to load. Titles show the category in parentheses, matching the plugin detail page: command/command group, event listener, event hook, or LLM tool.
|
||||
|
||||
@@ -189,7 +189,7 @@ Timeline chips show execution mode using the profile page's labels (`local` uses
|
||||
|
||||
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.
|
||||
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 placeholder is "Search messages, sessions, or plugins in X records". Summary cards show duration, plugin handler calls, and responses, without counting steps.
|
||||
|
||||
## Command Management
|
||||
|
||||
|
||||
@@ -172,9 +172,9 @@ ChatUI 支持以下常用能力:
|
||||
|
||||
### 追踪
|
||||
|
||||
追踪标签页不使用外层灰色卡片:提示栏、工具栏和列表直接铺在页面上,仅保留必要的分隔线,明暗主题均保持这一布局。
|
||||
追踪标签页不使用外层灰色卡片,工具栏和列表直接铺在页面上,仅保留必要的分隔线。原来的顶部说明和独立记录开关已移除;工具栏按钮显示“暂停”(启用且已连接)、“连接中”(启用但未连接)或“启动”(停用)。过滤器、记录按钮、刷新均采用图标加文字的浅灰按钮,与搜索框共用背景色,统一为 36px 高;搜索框填满剩余宽度,将操作按钮靠右排列。记录数量并入占位文字:“在 X 条记录中搜索消息、会话、插件”,不单独显示。点击记录按钮可暂停或恢复,更新期间显示加载状态并禁用重复点击;失败时保持原状态并提示错误。暂停不会清除已有列表或关闭详情,明暗主题保持相同布局。
|
||||
|
||||
`追踪` 页面实时显示消息执行链:收到消息、触发插件处理器、Agent 相关请求、发送响应和完成。默认只展示全宽 tracing 列表,列顺序为状态标签(运行中/已完成/错误)、平台 logo、时间戳、运行时间、消息、UMO、发送人和 Spans;运行时间在执行期间持续更新。点击某行后才打开右侧时间线详情,点击关闭按钮或按 Escape 返回列表;加载历史记录、新记录到达及筛选变化都不会自动打开其他记录的详情。窄屏列表可横向滚动。Spans 统计开始/结束合并后的时间线节点,同类型折叠不减少该数量,顶部记录数仍只统计消息。点击搜索框左侧的“过滤器”,勾选“有错误”“有模型调用”“有插件调用”;多选需同时满足所有条件,并可叠加搜索。不勾选则显示全部,“重置”清除勾选,关闭侧栏仍保留条件。原先的“全部 / 错误 / Agent / 插件”单选标签已改为多选侧栏。顶部开关可启用或禁用追踪记录。
|
||||
`追踪` 页面实时显示消息执行链:收到消息、触发插件处理器、Agent 相关请求、发送响应和完成。默认只展示全宽 Vuetify datatable 追踪列表,列顺序为状态标签(运行中/已完成/错误)、平台 logo、时间戳、运行时间、消息、UMO、发送人和 Spans;运行时间在执行期间持续更新。点击某行后才打开右侧时间线详情,点击关闭按钮或按 Escape 返回列表;加载历史记录、新记录到达及筛选变化都不会自动打开其他记录的详情。窄屏列表可横向滚动。Spans 统计开始/结束合并后的时间线节点,同类型折叠不减少该数量,搜索占位文字中的记录数仍只统计消息。点击搜索框左侧的“过滤器”,勾选“有错误”“有模型调用”“有插件调用”;多选需同时满足所有条件,并可叠加搜索。不勾选则显示全部,“重置”清除勾选,关闭侧栏仍保留条件。原先的“全部 / 错误 / Agent / 插件”单选标签已改为多选侧栏。工具栏记录按钮可启用或禁用追踪记录。
|
||||
|
||||
UMO 有别名时显示别名,带点划线的文字可悬停查看完整 UMO。插件优先显示 display name,悬停可查看插件 ID,同时展示 Handler 名称、事件类型及耗时。插件处理器节点优先使用插件 logo,没有 logo 或加载失败时使用插件卡片的默认蓝色星形图标;标题括号内展示与插件详情页一致的分类:指令/指令组、事件监听器、事件钩子或 LLM 工具。
|
||||
|
||||
@@ -189,7 +189,7 @@ UMO 有别名时显示别名,带点划线的文字可悬停查看完整 UMO。
|
||||
|
||||
时间线上连续的同类型节点默认折叠,例如“触发多个插件处理器(事件监听器)”,显示记录数量及叠放图标;点击可展开或收起每条记录。不同处理器分类或被其他类型节点隔开的记录不会合并,执行顺序保持不变。组内有错误时,折叠标题仍会提示错误数量,统计卡片仍按实际调用次数计算。
|
||||
|
||||
左侧消息列表及“收到消息”节点显示平台 logo;“收到消息”标题括号内显示 `platform_id`。没有匹配图标或图片加载失败时使用通用图标,处理状态仍通过文字显示。人格选择节点 `sel_persona` 显示为“选择人格”。搜索框提示为“搜索消息、会话、插件”,统计卡片显示耗时、插件处理器次数和发送响应次数,不展示步骤数。
|
||||
左侧消息列表及“收到消息”节点显示平台 logo;“收到消息”标题括号内显示 `platform_id`。没有匹配图标或图片加载失败时使用通用图标,处理状态仍通过文字显示。人格选择节点 `sel_persona` 显示为“选择人格”。搜索框提示为“在 X 条记录中搜索消息、会话、插件”,统计卡片显示耗时、插件处理器次数和发送响应次数,不展示步骤数。
|
||||
|
||||
## 指令管理
|
||||
|
||||
|
||||
Reference in New Issue
Block a user