Commit b5bd5e7127738a3f4c56bc464590d15a2adf67dd

Authored by yibo
1 parent a77934e7

feat(graph-console): 加「追加预算」按钮

run 撞墙钟/调用次数/token 上限 blocked 时,之前界面上没有入口,
只能开发者工具手搓 fetch 调 POST /:id/budget。加一个按钮 + 小弹窗,
三维增量(留空不追加)+ 追加后立即启动,复用现有的 act() 提交入口
与 confirmAuto 同款 modal 样式。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7xYyvv3pzuV9diMVUH5QL
web/src/components/graph-console/GraphRunPane.vue
@@ -28,6 +28,7 @@ @@ -28,6 +28,7 @@
28 <button class="btn" :disabled="busy || !can('pause')" @click="act('pause')">⏸ 暂停</button> 28 <button class="btn" :disabled="busy || !can('pause')" @click="act('pause')">⏸ 暂停</button>
29 <button class="btn" :disabled="busy || !can('stop')" @click="act('stop')">⏹ 停止</button> 29 <button class="btn" :disabled="busy || !can('stop')" @click="act('stop')">⏹ 停止</button>
30 <button class="btn retry" :disabled="busy || !canRetry" @click="onRetry">🔁 重试</button> 30 <button class="btn retry" :disabled="busy || !canRetry" @click="onRetry">🔁 重试</button>
  31 + <button class="btn budget" :disabled="busy || !canExtendBudget" @click="openBudgetModal">💰 追加预算</button>
31 <span v-if="nextNodes.length === 1" class="next-hint">下一步将执行 <code>{{ nextNode.name || nextNode.defNodeId }}</code></span> 32 <span v-if="nextNodes.length === 1" class="next-hint">下一步将执行 <code>{{ nextNode.name || nextNode.defNodeId }}</code></span>
32 <span v-else-if="nextNodes.length > 1" class="next-hint">自动模式下一批最多并发 <code>{{ nextNodes.length }}</code> 个: 33 <span v-else-if="nextNodes.length > 1" class="next-hint">自动模式下一批最多并发 <code>{{ nextNodes.length }}</code> 个:
33 <code v-for="n in nextNodes" :key="n.defNodeId">{{ n.name || n.defNodeId }}</code></span> 34 <code v-for="n in nextNodes" :key="n.defNodeId">{{ n.name || n.defNodeId }}</code></span>
@@ -67,6 +68,35 @@ @@ -67,6 +68,35 @@
67 </div> 68 </div>
68 </div> 69 </div>
69 </div> 70 </div>
  71 +
  72 + <!-- 追加预算:撞顶(尤其是墙钟)之前只能开发者工具手搓 fetch 调 POST /:id/budget,
  73 + 界面上没有入口。三维各自是**增量**(叠加在当前上限上,不是替换),
  74 + 留空的维度不追加——后端 extendBudget 本来就是这个语义(§四.C)。 -->
  75 + <div v-if="showBudgetModal" class="modal-back" @click.self="showBudgetModal = false">
  76 + <div class="modal">
  77 + <h3>追加预算</h3>
  78 + <p class="lead">这条 run 已经 blocked。三个维度都是往当前上限上加,留空 = 不追加那一维。</p>
  79 + <ul class="est" v-if="budget">
  80 + <li>当前剩余:<b>{{ remain.calls }}</b> 次调用 · <b>{{ fmtNum(remain.tokens) }}</b> tokens</li>
  81 + </ul>
  82 + <label class="budget-field">墙钟时长(小时)
  83 + <input type="number" min="0" step="1" v-model.number="budgetForm.hours" placeholder="例如 8" />
  84 + </label>
  85 + <label class="budget-field">调用次数
  86 + <input type="number" min="0" step="1" v-model.number="budgetForm.calls" placeholder="留空不追加" />
  87 + </label>
  88 + <label class="budget-field">tokens
  89 + <input type="number" min="0" step="1" v-model.number="budgetForm.tokens" placeholder="留空不追加" />
  90 + </label>
  91 + <label class="budget-resume">
  92 + <input type="checkbox" v-model="budgetForm.resume" /> 追加后立即启动(沿用当前的 手动/自动 选择)
  93 + </label>
  94 + <div class="modal-btns">
  95 + <button class="btn" @click="showBudgetModal = false">取消</button>
  96 + <button class="btn go" :disabled="!budgetHasAnyDelta" @click="submitBudget">追加</button>
  97 + </div>
  98 + </div>
  99 + </div>
70 </div> 100 </div>
71 </template> 101 </template>
72 102
@@ -116,6 +146,8 @@ const err = ref(&#39;&#39;); @@ -116,6 +146,8 @@ const err = ref(&#39;&#39;);
116 const notice = ref(''); 146 const notice = ref('');
117 const mode = ref('manual'); 147 const mode = ref('manual');
118 const confirmAuto = ref(false); 148 const confirmAuto = ref(false);
  149 +const showBudgetModal = ref(false);
  150 +const budgetForm = ref({ hours: 8, calls: '', tokens: '', resume: true });
119 const sseStatus = ref('idle'); 151 const sseStatus = ref('idle');
120 152
121 let es = null; 153 let es = null;
@@ -138,6 +170,14 @@ const nextNodes = computed(() =&gt; { @@ -138,6 +170,14 @@ const nextNodes = computed(() =&gt; {
138 const nextNode = computed(() => nextNodes.value[0] || null); // 手动模式「下一步」提示仍用单数 170 const nextNode = computed(() => nextNodes.value[0] || null); // 手动模式「下一步」提示仍用单数
139 const selectedNode = computed(() => nodes.value.find((n) => String(n.defNodeId) === selectedDefId.value) || null); 171 const selectedNode = computed(() => nodes.value.find((n) => String(n.defNodeId) === selectedDefId.value) || null);
140 const canRetry = computed(() => selectedNode.value && ['fail', 'blocked'].includes(selectedNode.value.status)); 172 const canRetry = computed(() => selectedNode.value && ['fail', 'blocked'].includes(selectedNode.value.status));
  173 +// 与后端 ensureStatus 同一张表(§四.C):/budget 只在 run_status='blocked' 时可用;
  174 +// budget 为 null 说明这一层不是挂预算的 root run(扣减与熔断只在 root run 上生效),
  175 +// 追加了也没有意义,按钮直接不亮
  176 +const canExtendBudget = computed(() => !!budget.value && run.value?.run_status === 'blocked');
  177 +const budgetHasAnyDelta = computed(() => {
  178 + const f = budgetForm.value;
  179 + return [f.hours, f.calls, f.tokens].some((v) => Number(v) > 0);
  180 +});
141 181
142 const remain = computed(() => { 182 const remain = computed(() => {
143 const b = budget.value || {}; 183 const b = budget.value || {};
@@ -277,6 +317,21 @@ function onStart() { @@ -277,6 +317,21 @@ function onStart() {
277 } 317 }
278 function onRetry() { if (canRetry.value) act('retry'); } 318 function onRetry() { if (canRetry.value) act('retry'); }
279 319
  320 +function openBudgetModal() {
  321 + budgetForm.value = { hours: 8, calls: '', tokens: '', resume: true };
  322 + showBudgetModal.value = true;
  323 +}
  324 +function submitBudget() {
  325 + const f = budgetForm.value;
  326 + const patch = {};
  327 + if (Number(f.hours) > 0) patch.wallclock_ms = Number(f.hours) * 3600 * 1000;
  328 + if (Number(f.calls) > 0) patch.total_agent_calls = Number(f.calls);
  329 + if (Number(f.tokens) > 0) patch.total_tokens = Number(f.tokens);
  330 + patch.resume = !!f.resume;
  331 + showBudgetModal.value = false;
  332 + act('budget', patch);
  333 +}
  334 +
280 function onSelect(node) { 335 function onSelect(node) {
281 selectedDefId.value = String(node.defNodeId); 336 selectedDefId.value = String(node.defNodeId);
282 emit('select', node); 337 emit('select', node);
@@ -374,6 +429,7 @@ function fmtNum(n) { return (n || 0).toLocaleString(); } @@ -374,6 +429,7 @@ function fmtNum(n) { return (n || 0).toLocaleString(); }
374 .btn:disabled { opacity:.4; cursor:not-allowed; } 429 .btn:disabled { opacity:.4; cursor:not-allowed; }
375 .btn.go { background:#1a7f37; border-color:#1a7f37; color:#fff; } 430 .btn.go { background:#1a7f37; border-color:#1a7f37; color:#fff; }
376 .btn.retry { border-color:#c96a00; color:#c96a00; } 431 .btn.retry { border-color:#c96a00; color:#c96a00; }
  432 +.btn.budget { border-color:#8a5cd6; color:#8a5cd6; }
377 .next-hint { font-size:11px; color:#666; } 433 .next-hint { font-size:11px; color:#666; }
378 .next-hint code { background:#eef4ff; padding:1px 5px; border-radius:3px; } 434 .next-hint code { background:#eef4ff; padding:1px 5px; border-radius:3px; }
379 .notice { color: var(--text-secondary); background: var(--bg-overlay); border: 1px dashed var(--border-primary); 435 .notice { color: var(--text-secondary); background: var(--bg-overlay); border: 1px dashed var(--border-primary);
@@ -389,4 +445,7 @@ function fmtNum(n) { return (n || 0).toLocaleString(); } @@ -389,4 +445,7 @@ function fmtNum(n) { return (n || 0).toLocaleString(); }
389 .est { font-size:12.5px; color:#444; padding-left:18px; margin:0 0 16px; } 445 .est { font-size:12.5px; color:#444; padding-left:18px; margin:0 0 16px; }
390 .est li { margin-bottom:4px; } 446 .est li { margin-bottom:4px; }
391 .modal-btns { display:flex; justify-content:flex-end; gap:8px; } 447 .modal-btns { display:flex; justify-content:flex-end; gap:8px; }
  448 +.budget-field { display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:12.5px; color:#444; margin-bottom:8px; }
  449 +.budget-field input { width:140px; font-size:12.5px; padding:3px 6px; border:1px solid #ddd; border-radius:4px; }
  450 +.budget-resume { display:flex; align-items:center; gap:6px; font-size:12.5px; color:#444; margin:4px 0 14px; }
392 </style> 451 </style>