NodeRunLog.vue 5.08 KB
<template>
  <div class="run-log">
    <!-- 双轨(§三.4 AgentChat.vue 的 live map + history list):
         进行中的这次 attempt 走 live 卡片持续追加;已完成的 attempt 落成历史卡片,
         外观一致、只少一个 LIVE 徽标,切换时内容不丢(两轨的数据来源是同一个事件缓冲) -->
    <div v-for="c in cards" :key="c.key" class="log-card" :class="c.status">
      <div class="log-card-head" @click="toggle(c.key)">
        <span class="ico">{{ icon(c.status) }}</span>
        <span class="who">attempt #{{ c.attempt + 1 }}</span>
        <span v-if="c.live" class="badge-live">LIVE</span>
        <span class="cnt">{{ c.lines.length }} 条</span>
        <span class="grow" />
        <span class="tog">{{ collapsed[c.key] ? '▶' : '▼' }}</span>
      </div>
      <div v-if="!collapsed[c.key]" class="log-card-body terminal">
        <div v-for="(l, i) in c.lines" :key="i" class="line">
          <span class="ts">{{ l.ts }}</span>
          <span class="kind">[{{ l.kind }}]</span>
          <span class="txt">{{ l.text }}</span>
        </div>
        <div v-if="!c.lines.length" class="muted">等待输出…</div>
      </div>
    </div>

    <!-- 这一轨是**引擎事件**(node.status / agent.call / node.output …)。
         §四.B.3 第 3 条还要求展示 agent 调用内部的对话与工具调用记录,那份数据目前
         只活在引擎进程内(AgentManager.callAgent 的 onAcpEvent / onData 回调),
         没有任何通道送到浏览器 —— 标清楚,不拿引擎事件冒充它。 -->
    <p class="track-note">
      引擎事件轨。<b>agent 调用内部</b>的对话 / 工具调用记录尚未透出(需引擎把 onAcpEvent 转成
      <code>agent.chunk</code> / <code>agent.tool</code> 事件),到位后在这里并轨显示。
    </p>

    <p v-if="!cards.length" class="muted pad">
      这个节点还没有事件。<br>
      事件来自 <code>GET /api/graph-runs/:id/stream</code>(带 <code>lastEventId=0</code> 全量重放)。
    </p>
  </div>
</template>

<script setup>
import { computed, reactive } from 'vue';

const props = defineProps({
  node: { type: Object, default: null },
  events: { type: Array, default: () => [] },   // GraphRunPane 的 SSE 缓冲(历史 + 实时同一条轨)
});

const collapsed = reactive({});
function toggle(k) { collapsed[k] = !collapsed[k]; }

/** 事件按 attempt 分组成卡片;当前 attempt 且节点在跑 = live 卡片,其余 = 历史卡片 */
const cards = computed(() => {
  const n = props.node;
  if (!n) return [];
  const groups = new Map();
  for (const e of props.events) {
    const d = e.data || {};
    const ref = d.node_run_id || d.ref;
    if (ref !== n.id) continue;
    const attempt = d.attempt != null ? d.attempt : (n.attempt || 0);
    if (!groups.has(attempt)) groups.set(attempt, []);
    groups.get(attempt).push({
      ts: fmtTs(e.ts),
      kind: e.kind,
      text: d.summary || d.to || JSON.stringify(stripNoise(d)),
    });
  }
  const live = n.status === 'running';
  return [...groups.entries()]
    .sort((a, b) => b[0] - a[0])
    .map(([attempt, lines]) => ({
      key: `${n.id}-${attempt}`,
      attempt,
      lines,
      live: live && attempt === (n.attempt || 0),
      status: live && attempt === (n.attempt || 0) ? 'running' : n.status,
    }));
});

function stripNoise(d) {
  const { run_id, root_run_id, node_run_id, ts, ...rest } = d || {};
  return rest;
}
function fmtTs(ts) {
  const d = new Date(typeof ts === 'string' ? Date.parse(ts) : ts);
  return Number.isNaN(d.getTime()) ? '--:--:--' : d.toTimeString().slice(0, 8);
}
function icon(s) {
  return s === 'running' ? '🚀' : s === 'success' ? '✅' : s === 'fail' ? '❌' : s === 'blocked' ? '⛔' : '⏳';
}
</script>

<style scoped>
.run-log { display:flex; flex-direction:column; gap:8px; }
.log-card { border:1px solid #e6e6ea; border-radius:6px; overflow:hidden; }
.log-card.running { border-color:#93b4e0; }
.log-card.fail { border-color:#e0a0a0; }
.log-card-head { display:flex; align-items:center; gap:8px; padding:6px 10px; background:#f7f7f9; cursor:pointer; font-size:12px; }
.grow { flex:1; }
.who { font-weight:600; }
.cnt { color:#999; font-size:11px; }
.badge-live { background:#d33; color:#fff; font-size:9px; padding:1px 5px; border-radius:7px; font-weight:700; }
.tog { color:#888; }
.log-card-body { max-height:280px; overflow-y:auto; padding:8px 10px; }
.terminal { background:#0f1115; color:#e6e6e6; font-family: ui-monospace, monospace; font-size:11px; }
.line { display:flex; gap:7px; margin-bottom:3px; }
.ts { color:#7da; flex:0 0 60px; }
.kind { color:#ffce6a; flex:0 0 auto; }
.txt { color:#bbd; overflow-wrap:anywhere; flex:1; }
.track-note { font-size:11px; color:#8a6d3b; background:#fdf8ec; border:1px solid #f0e2c0; border-radius:5px; padding:6px 8px; margin:0; line-height:1.6; }
.track-note code { background:#fff; border:1px solid #eadfc2; padding:0 4px; border-radius:3px; }
.muted { color:#999; font-size:12px; }
.muted code { background:#f0f0f2; padding:1px 4px; border-radius:3px; font-size:11px; }
.pad { padding:14px 4px; line-height:1.7; }
</style>