NodeTranscript.vue
8.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
<!--
「LLM 原文」tab —— 这个节点那次调用里 agent 真实说了什么、调了什么工具。
为什么单独一个 tab 而不是并进「运行日志」:那个 tab 读的是引擎的事件轨
(stream.log / timeline.ndjson),里面只有结构化事件、且自由文本被截断到 600 字。
引擎按设计不消费流式 chunk,所以逐字输出从来不在它的轨道上。这里读的是
Claude CLI 自己写的会话文件,惰性拉取、不进 SSE、不落第二份库。
三条必须写给用户的前提(都在界面上明说,不藏):
① transcript 按 (公司, 角色) 分,**不按节点分** —— 只能按节点的
started_at/completed_at 截时间窗口,是近似对齐。同一角色跑过多个节点时尤其要注意。
② 里面包含 agent 收到的**完整提示词**(写入边界、公司上下文都在内)。
默认折叠,展开需要点一下。
③ harness 每轮注入的 <env_status> 被服务端剥掉了,剥了几处如实显示。
-->
<template>
<section class="tx">
<div class="bar">
<button class="mini" :disabled="loading" @click="load">
{{ loading ? '读取中…' : '刷新' }}
</button>
<label class="chk">
<input v-model="showPrompt" type="checkbox" />
显示喂进去的内容(提示词 / 工具返回)
</label>
<span v-if="data && data.ok" class="muted small">
{{ shown.length }} / {{ data.entries.length }} 条
</span>
</div>
<p v-if="!node.systemAgentId" class="muted small">
这个节点没有绑定本体(subgraph 节点自己不调 agent)—— 原文在它子图各节点上。
</p>
<template v-else>
<p v-if="err" class="err small">{{ err }}</p>
<div v-else-if="data && !data.ok" class="degrade">
<div class="tag">找不到会话文件</div>
<p>
原因:<code>{{ data.reason }}</code>。找的是这个目录:
</p>
<p class="mono path">{{ data.dir }}</p>
<p>
目录名是按 Claude CLI 的命名规则(agent 的 cwd 路径把 <code>/</code> 换成
<code>-</code>)推出来的。这个 agent 从没被真实 CLI 调用过(比如 mock 模式跑的)
时目录不会存在 —— 那和「调用过但没说话」是两件事,所以这里说的是「找不到文件」,
不是「暂无数据」。
</p>
</div>
<template v-else-if="data">
<p class="muted small note">
按时间窗口对齐到本次调用({{ data.window.since || '开始时间未知' }}
→
{{ data.window.until || '仍在进行' }})。
<b>transcript 按公司/角色分,不按节点分</b>,同一角色跑过多个节点时这是近似对齐。
<span v-if="data.droppedNoTs">另有 {{ data.droppedNoTs }} 条没有时间戳、无法归属,已排除。</span>
<span v-if="data.strippedEnvStatus">
已剥掉 {{ data.strippedEnvStatus }} 处 harness 注入的 <code><env_status></code>。
</span>
</p>
<p v-if="!shown.length" class="muted small">
这个时间窗口里没有条目。<template v-if="!showPrompt">
勾上「显示喂进去的内容」看看是不是全是输入侧。</template>
</p>
<ol v-else class="feed">
<li v-for="(e, idx) in shown" :key="idx" :class="e.role">
<div class="e-head">
<span class="who" :class="e.role">{{ e.role === 'assistant' ? 'agent' : e.role }}</span>
<span class="ts mono">{{ shortTs(e.ts) }}</span>
</div>
<div v-for="(p, pi) in e.parts" :key="pi" class="part" :class="p.kind">
<template v-if="p.kind === 'text'">
<pre class="txt">{{ p.text }}</pre>
</template>
<template v-else-if="p.kind === 'thinking'">
<p class="think">◌ {{ p.text }}</p>
</template>
<template v-else-if="p.kind === 'tool_use'">
<p class="tool">→ <b>{{ p.name }}</b> <span class="mono">{{ p.text }}</span></p>
</template>
<template v-else>
<p class="result">← <span class="mono">{{ p.text }}</span></p>
</template>
<em v-if="p.clipped" class="hint">(此片段过长,已截断)</em>
</div>
</li>
</ol>
</template>
</template>
</section>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
const props = defineProps({
runId: { type: String, required: true },
node: { type: Object, required: true },
});
const loading = ref(false);
const err = ref('');
const data = ref(null);
// 默认只看 agent 说的话:喂进去的东西(提示词全文、工具返回)体量大且含写入边界等上下文,
// 要看得自己勾 —— 但**可以**看,不是藏起来
const showPrompt = ref(false);
const shown = computed(() => {
if (!data.value || !data.value.ok) return [];
if (showPrompt.value) return data.value.entries;
return data.value.entries.filter((e) => e.role === 'assistant');
});
function shortTs(ts) {
if (!ts) return '—';
// 只显示时分秒:同一次调用都在同一天,日期是噪音
return String(ts).slice(11, 19);
}
async function load() {
if (!props.node || !props.node.systemAgentId) return;
loading.value = true;
err.value = '';
try {
const r = await fetch(`/api/graph-runs/${props.runId}/nodes/${props.node.id}/transcript`);
const d = await r.json().catch(() => ({}));
if (!r.ok) {
err.value = d.error || `HTTP ${r.status}`;
return;
}
data.value = d;
} catch (e) {
err.value = e.message;
} finally {
loading.value = false;
}
}
// 换节点就重拉;**不轮询** —— 整份会话是几百 KB 量级,跟着 run 状态轮询会很贵,
// 正在跑的节点靠「刷新」按钮取最新
watch(() => [props.runId, props.node && props.node.id], () => { data.value = null; load(); }, { immediate: true });
</script>
<style scoped>
.tx { font-size: 13px; }
.bar { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.mini {
font-size: 12px; padding: 3px 10px; cursor: pointer;
border: 1px solid #ddd; border-radius: 5px; background: #fafafa; color: #1a1a1a;
}
.mini:disabled { opacity: .5; cursor: not-allowed; }
.chk { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #555; cursor: pointer; }
.note { line-height: 1.6; background: #f7f7f9; padding: 6px 8px; border-radius: 4px; }
.feed { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.feed > li { border-left: 3px solid #e4e4e8; padding-left: 10px; }
.feed > li.assistant { border-left-color: #15803d; }
.feed > li.user { border-left-color: #c7c7cf; }
.e-head { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
.who {
font-family: ui-monospace, Menlo, monospace; font-size: 10.5px; font-weight: 700;
text-transform: uppercase; letter-spacing: .05em; padding: 1px 6px; border-radius: 3px;
background: #f0f0f2; color: #666;
}
.who.assistant { background: #eaf5ee; color: #15703c; }
.ts { font-size: 10.5px; color: #999; }
.part + .part { margin-top: 5px; }
.txt {
margin: 0; font: inherit; white-space: pre-wrap; word-break: break-word; line-height: 1.65;
}
.think { margin: 0; color: #888; font-size: 12px; line-height: 1.5; }
.tool { margin: 0; color: #1d4ed8; font-size: 12px; }
.result { margin: 0; color: #777; font-size: 12px; }
.mono { font-family: ui-monospace, Menlo, monospace; font-size: 11.5px; word-break: break-all; }
.path { background: #f7f7f9; padding: 5px 8px; border-radius: 4px; }
.muted { color: #888; }
.small { font-size: 12px; }
.err { color: #c0392b; }
.hint { display: block; color: #b3480c; font-size: 11px; margin-top: 2px; }
.degrade { background: #fdf1e7; border: 1px dashed #e8c49a; border-radius: 6px; padding: 10px 12px; line-height: 1.6; }
.degrade .tag { font-size: 11px; font-weight: 700; color: #b3480c; margin-bottom: 4px; }
.degrade p { margin: 0 0 6px; font-size: 12px; }
</style>