GraphConsole.vue 16.1 KB
<template>
  <div class="console">
    <!-- 页头:公司 + 常驻预算条(§四.B.4) -->
    <header class="top">
      <div class="who">
        <router-link :to="`/${companyId}/overview`" class="back">←</router-link>
        <b>{{ company?.name || companyId }}</b>
        <span class="sep">组织与执行</span>
      </div>
      <div class="budget" v-if="budget">
        <span class="chip">
          <i>calls</i> {{ spent.agent_calls || 0 }}/{{ budget.total_agent_calls || '∞' }}
          <em class="bar"><em :style="{ width: pct(spent.agent_calls, budget.total_agent_calls) }" /></em>
        </span>
        <span class="chip">
          <i>tokens</i> {{ fmt(spent.tokens) }}/{{ fmt(budget.total_tokens) }}
        </span>
        <span class="chip"><i>墙钟</i> {{ elapsed }}</span>
      </div>
      <div class="budget" v-else>
        <span class="chip muted">无 root run 预算</span>
      </div>
    </header>

    <!-- 面包屑:公司 › org-coder › (子图)—— 与 §四.B.1 的图一致,点任一层回到那一层 -->
    <nav class="crumbs">
      <button class="crumb" :class="{ on: !stack.length }" @click="popTo(-1)">公司</button>
      <template v-for="(c, i) in stack" :key="c.runId">
        <span class="arrow">›</span>
        <button class="crumb" :class="{ on: i === stack.length - 1 }" @click="popTo(i)">{{ c.label }}</button>
      </template>
    </nav>

    <div class="cols">
      <!-- 左栏:org graph。与中栏是同一个画布组件的两个实例(§四.B.1 / R-15.4 的前端收益) -->
      <aside class="col left">
        <!-- 有图时「重新规划组织」是低频操作,收在标题栏上方即可;空态另有大按钮(R-18.1 / W8) -->
        <div v-if="hasOrgGraph || rootRun" class="left-actions">
          <ReplanOrgGraphButton :company-id="companyId" has-graph variant="inline" @planned="onReplanned" />
          <!-- run 在跑时不给下达入口:v1 约束是一家公司同时只有一个 active root run,
               端点会 409,与其让人点了吃报错,不如按钮就别出现(W14) -->
          <DispatchTaskButton
            v-if="!rootRunActive"
            :company-id="companyId"
            variant="inline"
            @dispatched="onDispatched"
          />
        </div>

        <!-- 有图、但从没跑过 run:这时候「下达任务」才是这个页面唯一该做的事,给主操作位 -->
        <div v-if="hasOrgGraph && !rootRun" class="dispatch-lead">
          <DispatchTaskButton :company-id="companyId" variant="primary" @dispatched="onDispatched" />
        </div>

        <GraphRunPane
          v-if="rootRun"
          :run-id="rootRun.id"
          title="Org Graph"
          compact
          @select="onOrgSelect"
          @state="onRootState"
        />
        <template v-else-if="hasOrgGraph">
          <header class="pane-head"><span class="ttl">Org Graph</span><span class="tag">定义(未执行)</span></header>
          <p class="hint">这家公司还没有 graph run,下面是 org graph 定义本身,节点状态一律 pending。</p>
          <WorkGraphCanvas :nodes="defNodes" :edges="defEdges" @select="onOrgSelect" />
        </template>

        <!-- 空态:整个控制台在这种公司上什么都做不了(没有 org graph 就没有 org node 可跑),
             所以入口在这里必须是主操作,而不是一句「先跑 orggraph-planning」的提示语 -->
        <div v-else class="org-empty">
          <div class="es-icon">🕸️</div>
          <h3>这家公司还没有 org graph</h3>
          <p>
            org graph 由 <code>orggraph-planning</code> 规划:它读公司目标选骨架、把图落库,
            再按拓扑序把图里的角色招进公司。规划完成前,控制台没有可执行的 org node。
          </p>
          <ReplanOrgGraphButton :company-id="companyId" variant="primary" @planned="onReplanned" />
        </div>
      </aside>

      <!-- 中栏:选中 org node 的 work graph(下钻后是子 run) -->
      <main class="col mid">
        <div v-if="!currentRunId" class="empty-state">
          <template v-if="orgNode">
            <!-- §四.B.5 空态:从未跑过 work graph,不报错不留白 -->
            <div class="es-icon">◔</div>
            <h3>{{ orgNode.name || orgNode.defNodeId }} 还没有跑过 work graph</h3>
            <p>
              org node 的 work graph 由 <code>workgraph-planning</code> 在它接到任务时现规划(§四.A.1)。
              这个节点被 <code>advanceOne</code> 拾取执行后,这里会出现它的图。
            </p>
          </template>
          <template v-else>
            <div class="es-icon">◧</div>
            <h3>从左栏选一个 org node</h3>
            <p>中栏显示该 org node 这次任务里跑的 work graph;控制按钮作用于当前这一层。</p>
          </template>
        </div>

        <template v-else>
          <div v-if="historyRuns.length > 1" class="history">
            <label>历史 work run</label>
            <select :value="stack[0]?.runId" @change="switchHistory($event.target.value)">
              <option v-for="r in historyRuns" :key="r.id" :value="r.id">
                {{ r.created_at }} · {{ r.run_status }} · {{ r.id }}
              </option>
            </select>
          </div>
          <GraphRunPane
            :key="currentRunId"
            :run-id="currentRunId"
            :title="stack[stack.length - 1]?.label || 'Work Graph'"
            @select="onNodeSelect"
            @state="onMidState"
          >
            <template #empty>这张 work graph 还没有节点。</template>
          </GraphRunPane>
        </template>
      </main>

      <!-- 右栏:节点详情四 tab -->
      <aside class="col right">
        <NodeDetailPanel
          :node="selectedNode"
          :loop-records="midState.loopRecords"
          :events="midState.events"
          @dive="dive"
        />
      </aside>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRoute } from 'vue-router';
import WorkGraphCanvas from '@/components/WorkGraphCanvas.vue';
import GraphRunPane from '@/components/graph-console/GraphRunPane.vue';
import NodeDetailPanel from '@/components/graph-console/NodeDetailPanel.vue';
import ReplanOrgGraphButton from '@/components/graph-console/ReplanOrgGraphButton.vue';
import DispatchTaskButton from '@/components/graph-console/DispatchTaskButton.vue';

/**
 * 执行控制台(T3 + T4,§四.B)。三栏:org graph / work graph 画布 / 节点详情。
 *
 * 层级模型直接对应 §四.B.1 的面包屑「公司 › org-coder › (子图)」:
 *   stack = []                       → 公司层(中栏提示选节点)
 *   stack = [work run]               → 某个 org node 的 work graph
 *   stack = [work run, 子 run, …]    → 逐层下钻,每层都是完整可交互的 pane(自带五个控制按钮)
 */
const route = useRoute();
const props = defineProps({ id: { type: String, default: '' } });
const companyId = computed(() => props.id || route.params.id);

const company = ref(null);
const rootRun = ref(null);
const allRuns = ref([]);
const defNodes = ref([]);
const defEdges = ref([]);

const orgNode = ref(null);                 // 左栏选中的 org node(run 状态合并后的对象)
const stack = ref([]);                     // 中栏层级栈
const selectedNode = ref(null);            // 右栏详情对象
const rootState = ref({});
const midState = ref({ loopRecords: [], events: [] });

const hasOrgGraph = computed(() => defNodes.value.length > 0);
/** run 在跑时端点会按 v1 并发约束 409,所以下达入口这时候直接不出现(W14) */
const ACTIVE_RUN = new Set(['ready', 'running', 'paused', 'blocked']);
const rootRunActive = computed(() => !!rootRun.value && ACTIVE_RUN.has(rootRun.value.run_status));
const currentRunId = computed(() => (stack.value.length ? stack.value[stack.value.length - 1].runId : null));
const budget = computed(() => rootState.value.budget || null);
const spent = computed(() => budget.value?.spent || {});

/** 同一个 org node 生命周期里可能跑过多张 work graph,默认最新、下拉切历史(§四.B.1) */
const historyRuns = computed(() =>
  orgNode.value ? allRuns.value.filter((r) => r.parent_node_run_id === orgNode.value.id) : []);

const now = ref(Date.now());
let tick = null;
const elapsed = computed(() => {
  const s0 = spent.value.started_at;
  if (!s0) return '—';
  const ms = now.value - Date.parse(s0);
  if (!Number.isFinite(ms) || ms < 0) return '—';
  const m = Math.floor(ms / 60000);
  return m < 60 ? `${m}min` : `${Math.floor(m / 60)}h ${m % 60}min`;
});

onMounted(async () => {
  tick = setInterval(() => { now.value = Date.now(); }, 30000);
  await Promise.all([loadCompany(), loadRuns(), loadOrgDef()]);
});
onBeforeUnmount(() => clearInterval(tick));

async function loadCompany() {
  try {
    const r = await fetch(`/api/company/${companyId.value}`);
    if (r.ok) company.value = await r.json();
  } catch { /* 页头名字掉了不影响功能 */ }
}

async function loadRuns() {
  try {
    const r = await fetch(`/api/company/${companyId.value}/graph-runs`);
    if (!r.ok) return;
    const j = await r.json();
    allRuns.value = j.runs || [];
    // v1 一家公司同时只有一个 active root run(§一非目标);没有 active 的就取最近一个 root
    const roots = allRuns.value.filter((x) => !x.parent_run_id);
    rootRun.value = roots.find((x) => ['ready', 'running', 'paused'].includes(x.run_status)) || roots[0] || null;
  } catch { /* 无 run 时左栏退到定义视图 */ }
}

/** 没有 run 时左栏用 org graph 定义画一张静态图,形态转成画布要的字段(org 节点全是 subgraph executor,§四.D.0) */
async function loadOrgDef() {
  try {
    const r = await fetch(`/api/company/${companyId.value}/org-graph`);
    if (!r.ok) return;
    const j = await r.json();
    defNodes.value = (j.nodes || []).map((n) => ({
      defNodeId: String(n.id), name: n.roleName, systemAgentId: n.systemAgentId,
      nodeKind: 'subgraph', status: 'pending', attempt: 0,
    }));
    defEdges.value = (j.edges || []).map((e) => ({
      // failReason 只在有值时出现在响应里(org-graph-service.getGraph),漏读它画布上就分不出
      // 「精确 fail 边」和「兜底边」——这两条在同一对节点之间可以并存
      id: e.id, from: String(e.from), to: String(e.to),
      condition: e.condition, failReason: e.failReason, edgeKind: e.edgeKind,
    }));
  } catch { /* ignore */ }
}

/**
 * 规划完成(org graph 指纹变了)后重载左栏。run 列表一起重拉:新图的节点与旧 run 的
 * def node 已经对不上了,不重拉的话左栏会拿旧 run 画一张不存在的图。
 * 选中态一并清掉,理由同上。
 */
async function onReplanned() {
  orgNode.value = null;
  selectedNode.value = null;
  stack.value = [];
  await Promise.all([loadOrgDef(), loadRuns()]);
}

/** 下达成功 → 立刻把新 run 拉进来,用户不用手动刷新(W14) */
async function onDispatched() {
  orgNode.value = null;
  selectedNode.value = null;
  stack.value = [];
  await loadRuns();
}

function onRootState(s) {
  rootState.value = s;
  // root run 的 childRuns 就是各 org node 这次跑的 work run。allRuns 只在挂载时拉过一次,
  // 运行中新开的子 run 不会进去,「历史 work run」下拉于是永远少最新那条 —— 用这份实时数据补上
  mergeRuns(s.childRuns);
}

function mergeRuns(list) {
  if (!list || !list.length) return;
  const seen = new Set(allRuns.value.map((r) => r.id));
  const add = list.filter((r) => !seen.has(r.id));
  if (add.length) allRuns.value = [...add, ...allRuns.value];
}
function onMidState(s) {
  midState.value = s;
  // 中栏刷新后把右栏详情指向同一条最新记录,否则详情面板会停在旧快照上
  if (selectedNode.value) {
    const fresh = (s.nodes || []).find((n) => n.id === selectedNode.value.id);
    if (fresh) selectedNode.value = fresh;
  }
}

function onOrgSelect(node) {
  orgNode.value = node;
  selectedNode.value = node;
  const runId = node.childRunId || historyRuns.value[0]?.id || null;
  stack.value = runId ? [{ runId, label: node.name || node.defNodeId }] : [];
}

function onNodeSelect(node) { selectedNode.value = node; }

/** 「子图」tab 的下钻:把子 run 推到栈顶,那一层的控制按钮就作用在子 run 上(§四.B.3) */
function dive({ runId, label }) {
  if (!runId) return;
  stack.value = [...stack.value, { runId, label: `${label || '子图'} ›子图` }];
  selectedNode.value = null;
}

function popTo(i) {
  stack.value = i < 0 ? [] : stack.value.slice(0, i + 1);
  selectedNode.value = null;
  if (i < 0) orgNode.value = null;
}

function switchHistory(runId) {
  const label = stack.value[0]?.label || 'work graph';
  stack.value = [{ runId, label }];
  selectedNode.value = null;
}

function pct(a, b) { return b ? `${Math.min(100, Math.round(((a || 0) / b) * 100))}%` : '0%'; }
function fmt(n) { return n == null ? '∞' : Number(n).toLocaleString(); }
</script>

<style scoped>
.console { display:flex; flex-direction:column; height:100vh; background:#fff; color:#1a1a1a; }

.top {
  display:flex; align-items:center; gap:16px; padding:10px 18px;
  border-bottom:1px solid #eee; flex:0 0 auto;
}
.who { display:flex; align-items:center; gap:9px; font-size:14px; }
.back { text-decoration:none; color:#888; font-size:16px; }
.sep { color:#999; font-size:12px; }
.budget { margin-left:auto; display:flex; gap:10px; align-items:center; }
.chip { font-size:11px; color:#444; background:#f7f7f9; border-radius:12px; padding:3px 10px; display:flex; align-items:center; gap:6px; }
.chip i { color:#999; font-style:normal; }
.chip.muted { color:#aaa; }
.bar { display:block; width:56px; height:4px; background:#e4e4e8; border-radius:2px; overflow:hidden; }
.bar em { display:block; height:100%; background:#2b6cb0; }

.crumbs { display:flex; align-items:center; gap:6px; padding:7px 18px; border-bottom:1px solid #f2f2f4; flex:0 0 auto; }
.crumb { border:none; background:none; cursor:pointer; font-size:12px; color:#2b6cb0; padding:2px 4px; }
.crumb.on { color:#1a1a1a; font-weight:600; cursor:default; }
.arrow { color:#ccc; font-size:12px; }

.cols { flex:1; display:grid; grid-template-columns:320px minmax(0, 1fr) 360px; min-height:0; }
.col { padding:12px 14px; overflow:auto; min-width:0; }
.left { border-right:1px solid #eee; background:#fcfcfd; }
.right { border-left:1px solid #eee; }

.pane-head { display:flex; align-items:center; gap:8px; font-size:13px; margin-bottom:6px; }
.ttl { font-weight:700; }
.tag { font-size:10px; color:#999; background:#f0f0f2; padding:1px 7px; border-radius:9px; }
.hint { font-size:11px; color:#999; margin:0 0 8px; line-height:1.6; }

.history { display:flex; align-items:center; gap:8px; margin-bottom:8px; font-size:12px; color:#666; }
.history select { font-size:11px; padding:3px 6px; border:1px solid #ddd; border-radius:4px; background:#fff; max-width:100%; }

.left-actions {
  display:flex; gap:6px; flex-wrap:wrap; align-items:flex-start;
  justify-content:flex-end; margin-bottom:8px; padding:8px 12px 0;
}

/* 有图未跑:下达任务是这时候唯一该做的事,给它主操作的位置与留白 */
.dispatch-lead { padding: 10px 12px; border-bottom: 1px solid var(--border-primary); }

/* 左栏空态:控制台在无图公司上唯一能做的事,所以按钮是主操作而不是脚注 */
.org-empty { text-align:center; color:#888; padding:34px 10px; }
.org-empty h3 { font-size:13px; color:#555; margin:10px 0 6px; }
.org-empty p { font-size:11px; line-height:1.9; margin:0 0 14px; }
.org-empty code { background:#f2f2f4; padding:1px 5px; border-radius:3px; }

.empty-state { text-align:center; color:#888; padding:60px 24px; }
.es-icon { font-size:34px; color:#d8d8dc; }
.empty-state h3 { font-size:14px; color:#555; margin:10px 0 6px; }
.empty-state p { font-size:12px; line-height:1.8; max-width:460px; margin:0 auto; }
.empty-state code { background:#f2f2f4; padding:1px 5px; border-radius:3px; }

@media (max-width: 1100px) {
  .cols { grid-template-columns:1fr; }
  .left, .right { border:none; border-bottom:1px solid #eee; }
}
</style>