GraphRunPane.vue 11.5 KB
<template>
  <div class="pane" :class="{ compact }">
    <header class="pane-head">
      <span class="ttl">{{ title }}</span>
      <span v-if="run" class="run-status" :class="run.run_status">{{ run.run_status }}</span>
      <span v-if="run" class="rid" :title="run.id">{{ run.id }}</span>
      <span class="grow" />
      <span class="conn" :class="sseStatus" :title="`SSE: ${sseStatus}`">●</span>
      <button class="mini" :disabled="loading" @click="load()">刷新</button>
    </header>

    <!-- 控制栏(§四.C 五个操作;作用于本 pane 当前显示的这一层 run) -->
    <div v-if="run" class="ctrl">
      <label class="mode">
        <select v-model="mode" :disabled="busy">
          <option value="manual">手动</option>
          <option value="auto">自动</option>
        </select>
      </label>
      <button class="btn go"    :disabled="busy || !can('start')"  @click="onStart">▶ 启动</button>
      <button class="btn"       :disabled="busy || !can('next')"   @click="act('next')">⏭ 下一步</button>
      <button class="btn"       :disabled="busy || !can('pause')"  @click="act('pause')">⏸ 暂停</button>
      <button class="btn"       :disabled="busy || !can('stop')"   @click="act('stop')">⏹ 停止</button>
      <button class="btn retry" :disabled="busy || !canRetry"      @click="onRetry">🔁 重试</button>
      <span v-if="nextNode" class="next-hint">下一步将执行 <code>{{ nextNode.name || nextNode.defNodeId }}</code></span>
    </div>

    <p v-if="err" class="err">{{ err }}</p>

    <WorkGraphCanvas
      :nodes="nodes"
      :edges="edges"
      :loop-records="loopRecords"
      :selected-id="selectedDefId"
      :next-node-id="nextNode ? String(nextNode.defNodeId) : null"
      @select="onSelect"
    >
      <template #empty><slot name="empty">这张图还没有节点</slot></template>
    </WorkGraphCanvas>

    <!-- §四.B.4:自动模式启动前的预估成本确认,不是可选的礼貌 -->
    <div v-if="confirmAuto" class="modal-back" @click.self="confirmAuto = false">
      <div class="modal">
        <h3>自动模式启动前确认</h3>
        <p class="lead">自动模式一旦启动,引擎会连续调用 agent 直到收敛或撞预算,中途只能靠「暂停 / 停止」拦。</p>
        <ul class="est">
          <li>本次任务粗估:<b>60-100 次 agent 调用 · $10-20 · 1-2 小时</b>(27-P3 §四.A.3 量级)</li>
          <li v-if="budget">
            剩余预算:<b>{{ remain.calls }}</b> 次调用 ·
            <b>{{ fmtNum(remain.tokens) }}</b> tokens
          </li>
          <li v-else>该 run 没挂预算(budget_json 为空)——扣减与熔断只在 root run 上生效。</li>
        </ul>
        <div class="modal-btns">
          <button class="btn" @click="confirmAuto = false">取消</button>
          <button class="btn go" @click="confirmAuto = false; act('start', { mode: 'auto' })">确认并启动</button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import WorkGraphCanvas from '../WorkGraphCanvas.vue';

/**
 * 一层 run 的完整视图(T3/T4):拉状态 + 控制栏 + 画布 + SSE。
 * 一个页面上会挂多个实例(左栏 org run、中栏 work run、下钻后的子 run),彼此独立。
 *
 * 事件轨:SSE 带 ?lastEventId=0 连接 —— graph-run-sse 的 replayTo 会把 stream.log 从头重放,
 * 所以**一条连接同时拿到历史与实时**,「运行日志」tab 的 history 轨不需要另开端点。
 */
const props = defineProps({
  runId: { type: String, default: null },
  title: { type: String, default: 'Graph Run' },
  compact: { type: Boolean, default: false },
});
const emit = defineEmits(['select', 'state']);

const run = ref(null);
const nodes = ref([]);
const edges = ref([]);
const loopRecords = ref([]);
const budget = ref(null);
const events = ref([]);          // SSE 事件缓冲,供详情面板的「运行日志」tab 消费
const selectedDefId = ref(null);
const loading = ref(false);
const busy = ref(false);
const err = ref('');
const mode = ref('manual');
const confirmAuto = ref(false);
const sseStatus = ref('idle');

let es = null;
let refreshTimer = null;
let pollTimer = null;
const MAX_EVENTS = 400;

/** 引擎按 (created_at, id) 取第一个 ready 节点;getRunState 用同样的排序,取首个 ready 即「下一步会跑哪个」 */
const nextNode = computed(() => nodes.value.find((n) => n.status === 'ready') || null);
const selectedNode = computed(() => nodes.value.find((n) => String(n.defNodeId) === selectedDefId.value) || null);
const canRetry = computed(() => selectedNode.value && ['fail', 'blocked'].includes(selectedNode.value.status));

const remain = computed(() => {
  const b = budget.value || {};
  const spent = b.spent || {};
  return {
    calls: Math.max(0, (b.total_agent_calls || 0) - (spent.agent_calls || 0)),
    tokens: Math.max(0, (b.total_tokens || 0) - (spent.tokens || 0)),
  };
});

const ALLOW = {
  start: ['ready', 'paused', 'blocked'],
  next: ['running', 'paused'],
  pause: ['running'],
  stop: ['running', 'paused', 'blocked'],
};
function can(op) { return run.value && ALLOW[op].includes(run.value.run_status); }

async function load() {
  if (!props.runId) { run.value = null; nodes.value = []; edges.value = []; return; }
  loading.value = true;
  try {
    const r = await fetch(`/api/graph-runs/${props.runId}`);
    if (!r.ok) { err.value = `加载失败 HTTP ${r.status}`; return; }
    const j = await r.json();
    err.value = '';
    run.value = j.run;
    nodes.value = j.nodes || [];
    edges.value = j.edges || [];
    loopRecords.value = j.loopRecords || [];
    budget.value = j.budget || null;
    mode.value = j.run?.execution_mode || 'manual';
    push();
  } catch (e) {
    err.value = e.message;
  } finally {
    loading.value = false;
  }
}

function push() {
  emit('state', {
    run: run.value, nodes: nodes.value, edges: edges.value,
    loopRecords: loopRecords.value, budget: budget.value, events: events.value,
  });
}

/** 控制操作统一入口:五个端点形态一致,失败时把后端给的 error 直接显示(它已经写清了为什么不可用) */
async function act(op, body) {
  busy.value = true;
  try {
    const url = op === 'retry'
      ? `/api/graph-runs/${props.runId}/nodes/${selectedNode.value.id}/retry`
      : `/api/graph-runs/${props.runId}/${op}`;
    const r = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body || {}),
    });
    const j = await r.json().catch(() => ({}));
    err.value = r.ok ? '' : (j.error || `HTTP ${r.status}`);
  } catch (e) {
    err.value = e.message;
  } finally {
    busy.value = false;
    await load();
  }
}

function onStart() {
  if (mode.value === 'auto') { confirmAuto.value = true; return; }
  act('start', { mode: 'manual' });
}
function onRetry() { if (canRetry.value) act('retry'); }

function onSelect(node) {
  selectedDefId.value = String(node.defNodeId);
  emit('select', node);
}

// ── SSE ────────────────────────────────────────────────────────────
function connect() {
  disconnect();
  if (!props.runId) return;
  sseStatus.value = 'connecting';
  try {
    // lastEventId=0 → 服务端 replayTo 从头重放 stream.log,历史 + 实时一条连接搞定
    es = new EventSource(`/api/graph-runs/${props.runId}/stream?lastEventId=0`);
  } catch { sseStatus.value = 'error'; return; }

  es.onopen = () => { sseStatus.value = 'connected'; };
  es.onerror = () => { sseStatus.value = 'reconnecting'; };   // EventSource 自带重连,不手工重建
  for (const k of ['node.status', 'run.status', 'loop.registered', 'budget.spent', 'agent.call', 'node.output']) {
    es.addEventListener(k, (ev) => onEvent(k, ev));
  }
  es.onmessage = (ev) => onEvent(ev.type || 'message', ev);
}

function onEvent(kind, ev) {
  let data = {};
  try { data = JSON.parse(ev.data); } catch { data = { raw: ev.data }; }
  events.value.push({ id: ev.lastEventId, kind, data, ts: data.ts || Date.now() });
  if (events.value.length > MAX_EVENTS) events.value.splice(0, events.value.length - MAX_EVENTS);
  // 状态类事件去抖后重拉一次全量——SSE 帧只带增量,画布要的是一致快照
  if (kind === 'node.status' || kind === 'run.status' || kind === 'loop.registered') {
    clearTimeout(refreshTimer);
    refreshTimer = setTimeout(load, 300);
  } else {
    push();
  }
}

function disconnect() {
  if (es) { try { es.close(); } catch { /* ignore */ } es = null; }
  clearTimeout(refreshTimer);
}

// ponytail: 引擎侧的 SSE 接线(T5→T2)尚未完成时事件轨是空的,靠这个 6s 轮询兜底,
// 只在 run 真的在跑的时候转。等引擎接上 emitTransition 后可以摘掉。
function startPoll() {
  clearInterval(pollTimer);
  pollTimer = setInterval(() => {
    if (run.value && ['running', 'planning'].includes(run.value.run_status)) load();
  }, 6000);
}

watch(() => props.runId, () => {
  selectedDefId.value = null;
  events.value = [];
  load();
  connect();
}, { immediate: true });

startPoll();
onBeforeUnmount(() => { disconnect(); clearInterval(pollTimer); });

function fmtNum(n) { return (n || 0).toLocaleString(); }
</script>

<style scoped>
.pane { display:flex; flex-direction:column; gap:8px; min-width:0; }
.pane-head { display:flex; align-items:center; gap:8px; font-size:13px; }
.ttl { font-weight:700; }
.grow { flex:1; }
.rid { font-family: ui-monospace, monospace; font-size:10px; color:#aaa; max-width:130px; overflow:hidden; text-overflow:ellipsis; }
.run-status { font-size:10px; padding:1px 7px; border-radius:9px; background:#eee; color:#555; text-transform:uppercase; }
.run-status.running   { background:#cce5ff; color:#004085; }
.run-status.completed { background:#d4edda; color:#155724; }
.run-status.blocked   { background:#ffe8cc; color:#a35b00; }
.run-status.failed    { background:#f8d7da; color:#721c24; }
.run-status.paused    { background:#fff3cd; color:#856404; }
.conn { font-size:11px; color:#ccc; }
.conn.connected { color:#0a6; }
.conn.reconnecting, .conn.connecting { color:#c70; }
.conn.error { color:#c00; }

.ctrl { display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
.ctrl select { font-size:12px; padding:3px 6px; border:1px solid #ddd; border-radius:4px; background:#fff; }
.btn, .mini {
  border:1px solid #ddd; background:#fff; border-radius:5px; cursor:pointer;
  font-size:12px; padding:4px 10px; color:#333;
}
.mini { font-size:11px; padding:2px 8px; color:#777; }
.btn:hover:not(:disabled) { border-color:#999; }
.btn:disabled { opacity:.4; cursor:not-allowed; }
.btn.go { background:#1a7f37; border-color:#1a7f37; color:#fff; }
.btn.retry { border-color:#c96a00; color:#c96a00; }
.next-hint { font-size:11px; color:#666; }
.next-hint code { background:#eef4ff; padding:1px 5px; border-radius:3px; }
.err { color:#c00; font-size:12px; margin:0; background:#fff5f5; border-left:3px solid #c00; padding:5px 8px; }

.compact .ctrl .btn { padding:3px 7px; font-size:11px; }

.modal-back { position:fixed; inset:0; background:rgba(0,0,0,.35); display:flex; align-items:center; justify-content:center; z-index:950; }
.modal { background:#fff; border-radius:10px; padding:20px 24px; width:460px; max-width:92vw; box-shadow:0 12px 40px rgba(0,0,0,.2); }
.modal h3 { margin:0 0 8px; font-size:15px; }
.lead { font-size:13px; color:#555; margin:0 0 10px; }
.est { font-size:12.5px; color:#444; padding-left:18px; margin:0 0 16px; }
.est li { margin-bottom:4px; }
.modal-btns { display:flex; justify-content:flex-end; gap:8px; }
</style>