GraphConsole.vue 19.2 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 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421
<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">
        <!-- 账本与事件轨必须取**选中节点所属那一层**的:选中的是左栏 org node 时,
             它的回退记录与事件在 root run 上,中栏那份是另一张图的(W12-UI 修的第三处) -->
        <!-- nodes/edges/taskFiles/companyRoot 同样取**选中节点所属那一层**:R-19 的输入解析
             是「本层入边的契约 × 本 task 的产物实例」,拿错层的边就会解析出别的图的输入 -->
        <NodeDetailPanel
          :node="selectedNode"
          :loop-records="selectedState.loopRecords || []"
          :events="selectedState.events || []"
          :nodes="selectedState.nodes || []"
          :edges="selectedState.edges || []"
          :files="selectedState.taskFiles || null"
          :company-root="selectedState.companyRoot || null"
          :company-id="companyId"
          @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);            // 右栏详情对象
/**
 * 右栏这个节点是从哪一栏选的('root' = 左栏 org run,'mid' = 中栏 work run)。
 *
 * 这一个字段是「NodeDetailPanel 不跟 SSE 走」那个缺陷的修法(27-P3B 记录的已知缺陷):
 * 在此之前只有 onMidState 会把 selectedNode 指到最新快照,onRootState 完全不管,
 * 于是**左栏选中的 org node 在右栏是一张永久冻结的快照** —— 左栏已经 RUNNING·#2,
 * 右栏还是 BLOCKED,要点别的节点再点回来才刷新。
 * 知道来源之后,两栏各自的 SSE 刷新都能喂到右栏,回退账本 / 事件轨也不会串层。
 */
const selectedFrom = ref('mid');
const rootState = ref({});
const midState = ref({ loopRecords: [], events: [] });
const selectedState = computed(() => (selectedFrom.value === 'root' ? rootState.value : midState.value));

const hasOrgGraph = computed(() => defNodes.value.length > 0);
/**
 * 能不能下达任务,**由服务端算**(GET /graph-runs 的 canDispatch)。
 *
 * 这里原来自己复述了一份取值域,而且比后端多写了一个 blocked ——
 * 后果是一家公司只要失败过一次(run 收敛成 blocked),控制台上就再也下不了第二个任务,
 * 而后端其实是放行的。规则只留服务端一处,前端照抄结果(P3B 第三次真跑发现)。
 * 拿不到字段时保守放行,让端点的 409 去兜底 —— 那条路至少有明确报错。
 */
const canDispatch = ref(true);
const rootRunActive = computed(() => !canDispatch.value);
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 || [];
    // 服务端算好的「能不能下达」;字段缺失(旧后端)时保守放行,交给端点 409 兜底
    canDispatch.value = j.canDispatch !== false;
    // 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);

  // 左栏节点也要跟着 SSE 走。orgNode 是中栏 runId 的来源,selectedNode 是右栏详情的来源,
  // 两者可能指向同一行也可能不是(选了 org node 之后又去中栏点了 work node)。
  const fresh = (id) => (s.nodes || []).find((n) => n.id === id) || null;
  if (orgNode.value) {
    const next = fresh(orgNode.value.id);
    if (next) {
      orgNode.value = next;
      // org node 是在**被拾取执行时**才现规划出 work graph 的:选中它的那一刻多半还没有
      // childRunId。子 run 一出现就把中栏指过去,不然用户得手动再点一次这个节点。
      // 只在栈空时做 —— 用户已经下钻到某一层时不能把他的位置抢走。
      if (next.childRunId && !stack.value.length) {
        stack.value = [{ runId: next.childRunId, label: next.name || next.defNodeId }];
      }
    }
  }
  if (selectedFrom.value === 'root' && selectedNode.value) {
    selectedNode.value = fresh(selectedNode.value.id) || selectedNode.value;
  }
}

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 (selectedFrom.value === 'mid' && 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;
  selectedFrom.value = 'root';
  const runId = node.childRunId || historyRuns.value[0]?.id || null;
  stack.value = runId ? [{ runId, label: node.name || node.defNodeId }] : [];
}

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

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

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>