NodeTranscript.vue 8.07 KB
<!--
  「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>&lt;env_status&gt;</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>