GraphRunPane.vue 22.8 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 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451
<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>
      <!-- 「点过了、还没落地」的软信号(pause_requested / stop_requested)。
           run_status 要到节点边界才变,不读这两列的话用户点完暂停看不到任何反馈 -->
      <span v-if="pendingCtl" class="req" :title="pendingCtl.hint">{{ pendingCtl.label }}</span>
      <!-- run 树级回退熔断计数(§四.D.2.1 的外层硬顶)。画布上的 N/3 是单边计数,
           「换个借口继续退」只有这一维拦得住,之前它落了库但没人读回 -->
      <span v-if="backwardCount" class="req back" :title="backwardHint">回退 {{ backwardCount }}{{ isRoot ? `/${LOOP_HARD_CAP_RUN}` : '' }}</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>
      <button class="btn budget" :disabled="busy || !canExtendBudget" @click="openBudgetModal">💰 追加预算</button>
      <span v-if="nextNodes.length === 1" class="next-hint">下一步将执行 <code>{{ nextNode.name || nextNode.defNodeId }}</code></span>
      <span v-else-if="nextNodes.length > 1" class="next-hint">自动模式下一批最多并发 <code>{{ nextNodes.length }}</code> 个:
        <code v-for="n in nextNodes" :key="n.defNodeId">{{ n.name || n.defNodeId }}</code></span>
    </div>

    <p v-if="err" class="err">{{ err }}</p>
    <!-- 200 但结果不是用户以为的那个:坑满 → 退回队列。不说的话界面上什么都看不出来 -->
    <p v-if="notice" class="notice">{{ notice }}</p>

    <WorkGraphCanvas
      :nodes="nodes"
      :edges="edges"
      :loop-records="loopRecords"
      :selected-id="selectedDefId"
      :next-node-ids="nextNodes.map((n) => String(n.defNodeId))"
      @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>

    <!-- 追加预算:撞顶(尤其是墙钟)之前只能开发者工具手搓 fetch 调 POST /:id/budget,
         界面上没有入口。三维各自是**增量**(叠加在当前上限上,不是替换),
         留空的维度不追加——后端 extendBudget 本来就是这个语义(§四.C)。 -->
    <div v-if="showBudgetModal" class="modal-back" @click.self="showBudgetModal = false">
      <div class="modal">
        <h3>追加预算</h3>
        <p class="lead">这条 run 已经 blocked。三个维度都是往当前上限上加,留空 = 不追加那一维。</p>
        <ul class="est" v-if="budget">
          <li>当前剩余:<b>{{ remain.calls }}</b> 次调用 · <b>{{ fmtNum(remain.tokens) }}</b> tokens</li>
        </ul>
        <label class="budget-field">墙钟时长(小时)
          <input type="number" min="0" step="1" v-model.number="budgetForm.hours" placeholder="例如 8" />
        </label>
        <label class="budget-field">调用次数
          <input type="number" min="0" step="1" v-model.number="budgetForm.calls" placeholder="留空不追加" />
        </label>
        <label class="budget-field">tokens
          <input type="number" min="0" step="1" v-model.number="budgetForm.tokens" placeholder="留空不追加" />
        </label>
        <label class="budget-resume">
          <input type="checkbox" v-model="budgetForm.resume" /> 追加后立即启动(沿用当前的 手动/自动 选择)
        </label>
        <div class="modal-btns">
          <button class="btn" @click="showBudgetModal = false">取消</button>
          <button class="btn go" :disabled="!budgetHasAnyDelta" @click="submitBudget">追加</button>
        </div>
      </div>
    </div>
  </div>
</template>

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

/**
 * 一层 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 childRuns = ref([]);       // 路由一直在回这个字段,之前 load() 直接丢掉(见 push 的注释)
const budget = ref(null);
// R-19 产物索引的两个字段:taskFiles = 本 task 的产物实例(graph_files 当前有效行),
// companyRoot = 公司根绝对路径(库里只存相对路径,根由后端按 companyRootDir 推导)。
// 后端还没吐这两个字段时 taskFiles 保持 null —— 详情面板据此**整块降级**而不是显示空列表,
// 「没有产物」和「后端没告诉我有没有产物」必须分得开。
const taskFiles = ref(null);
const companyRoot = ref(null);
// 这个任务真实交付物的落点(W2「每个 root run 一个项目根」)——与 taskFiles 是两类数据,
// 不要合并:graph_files 只索引节点私有目录,projectRoot 才是代码真正写的地方。
// 没有记录(节点还没跑到 ensureProjectRoot 那一步)时保持 null。
const projectRoot = ref(null);
// 35 号文档提案 B:这个节点最近一次 benchmark 判决按维度标了有没有证据支撑,纯展示。
const benchmarkVerdicts = ref([]);
const events = ref([]);          // SSE 事件缓冲,供详情面板的「运行日志」tab 消费
const selectedDefId = ref(null);
const loading = ref(false);
const busy = ref(false);
const err = ref('');
/** 非错误、但必须让用户看见的一句话(例:启动被退回队列)。与 err 互斥。 */
const notice = ref('');
const mode = ref('manual');
const confirmAuto = ref(false);
const showBudgetModal = ref(false);
const budgetForm = ref({ hours: 8, calls: '', tokens: '', resume: true });
const sseStatus = ref('idle');

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

// 27-P4 T4:手动模式的「下一步」(nextStep→advanceOne)仍然只推一个,但自动模式的
// advanceBatch 一次最多认领 run.max_concurrency 个 ready 节点——「下一步会跑哪个」
// 不再是单数。引擎按 (created_at, id) 取候选,getRunState 用同样的排序返回 nodes,
// 取前 N 个 ready 即「下一批大概率会认领谁」。
// ponytail: 这是提示,不是承诺——真实拾取时若跟别的 advanceBatch 撞了车(§4.1 决策),
// 实际认领的集合可能有出入;纯展示用途,不影响任何控制逻辑,不值得为了精确复刻
// 后端排序再传一次 max_concurrency 的默认值同步逻辑。
const DEFAULT_MAX_CONCURRENCY_HINT = 5; // 与 work-graph-db.DEFAULT_MAX_CONCURRENCY 对齐,纯展示兜底
const nextNodes = computed(() => {
  const cap = (run.value && run.value.max_concurrency) || DEFAULT_MAX_CONCURRENCY_HINT;
  return nodes.value.filter((n) => n.status === 'ready').slice(0, cap);
});
const nextNode = computed(() => nextNodes.value[0] || 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));
// 与后端 ensureStatus 同一张表(§四.C):/budget 只在 run_status='blocked' 时可用;
// budget 为 null 说明这一层不是挂预算的 root run(扣减与熔断只在 root run 上生效),
// 追加了也没有意义,按钮直接不亮
const canExtendBudget = computed(() => !!budget.value && run.value?.run_status === 'blocked');
const budgetHasAnyDelta = computed(() => {
  const f = budgetForm.value;
  return [f.hours, f.calls, f.tokens].some((v) => Number(v) > 0);
});

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)),
  };
});

// 与 routes/graph-run.js 的 ensureStatus 同一张表(§四.C「可用条件」那一列)
const ALLOW = {
  start: ['ready', 'paused', 'blocked'],   // 外加 running + pause_requested,见 can()
  next: ['running', 'paused'],
  pause: ['running'],
  stop: ['running', 'paused', 'blocked'],
};
function can(op) {
  const r = run.value;
  if (!r) return false;
  // 已经在途的同一个操作不再可点:软信号已置位、只是还没到落库的节点边界,
  // 再点一次既不会更快也没有反馈(此前只看 run_status,按钮一直是亮的)
  if (op === 'pause' && r.pause_requested) return false;
  if (op === 'stop' && r.stop_requested) return false;
  // 「暂停中」(running + pause_requested,暂停还没落到节点边界)同样可以点启动 = 撤回暂停意图。
  // org 层每个节点都是 subgraph,子 run 在飞时父节点恒为 running,landPause 因 busy 永远落不下来,
  // 只认 run_status 的话这一层的启动按钮永久置灰,用户只剩「下一步」一条路(R6-F3 问题 1)。
  if (op === 'start' && r.run_status === 'running') return Boolean(r.pause_requested) && !r.stop_requested;
  return ALLOW[op].includes(r.run_status);
}

const TERMINAL = ['completed', 'failed', 'stopped'];

/**
 * run 行上的 pause_requested / stop_requested —— 后端一直在写、前端一直没读的那两列。
 *
 * 「暂停」是软信号:引擎在**节点边界**才把 running 落成 paused(landPause),
 * 期间 run_status 仍是 running。预算撞顶时引擎也会把整棵树置 pause_requested=1
 * 而只把 root 置 blocked —— 子 run 面板于是显示 running 却再也不动,无从解释。
 * 落地之后(status 已经是 paused/stopped)就不再重复提示,交给 run_status 徽标。
 */
const pendingCtl = computed(() => {
  const r = run.value;
  if (!r || TERMINAL.includes(r.run_status)) return null;
  if (r.stop_requested) return { label: '停止中', hint: '已请求停止:不再拾取新节点,手头节点跑完即落 stopped' };
  if (r.pause_requested && r.run_status === 'running') {
    return { label: '暂停中', hint: '已请求暂停:不打断当前节点,到节点边界才落 paused(预算撞顶时也会级联置位)' };
  }
  return null;
});

// ponytail: 与 graph-engine.LOOP_HARD_CAP_RUN 重复的常量(API 没吐这个上限)。
// 上限一旦改,这里要一起改;升级路径是 GET /api/graph-runs/:id 顺带回 caps。
const LOOP_HARD_CAP_RUN = 5;
const isRoot = computed(() => !!run.value && run.value.root_run_id === run.value.id);
const backwardCount = computed(() => (run.value && run.value.backward_count) || 0);
const backwardHint = computed(() => (isRoot.value
  ? `整棵 run 树累计回退次数 / run 级硬顶 ${LOOP_HARD_CAP_RUN}(§四.D.2.1);越顶后不再备案,直接收敛等人工`
  : '本层累计回退次数;熔断判据是 root run 那一行的全树计数'));

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 || [];
    childRuns.value = j.childRuns || [];
    budget.value = j.budget || null;
    taskFiles.value = Array.isArray(j.taskFiles) ? j.taskFiles : null;
    companyRoot.value = j.companyRoot || null;
    projectRoot.value = j.projectRoot || null;
    benchmarkVerdicts.value = Array.isArray(j.benchmarkVerdicts) ? j.benchmarkVerdicts : [];
    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,
    // childRuns 随每次 load 刷新,是「运行中新开的子 run」唯一的实时来源;
    // 页面挂载时那一次公司级 run 列表不会自己更新(R5-F2 字段对账)
    childRuns: childRuns.value,
    // R-19:右栏「输入输出」tab 的实例侧数据,与 nodes/edges 走同一条 load
    taskFiles: taskFiles.value, companyRoot: companyRoot.value,
    projectRoot: projectRoot.value,
    benchmarkVerdicts: benchmarkVerdicts.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(() => ({}));
    // ⚠️ 200 不等于「你要的事发生了」。坑满时「启动」会走 admitOrRequeue 把这条 run
    // **退回队列**,返回的是 200 + { ok:true, requeued:true, runStatus:'queued' }。
    // 只判 r.ok 的话界面上一个字都不会说,而随后按钮全灰、run 面板换成定义视图 ——
    // 用户点了「启动」,得到的是「什么都没说、图不见了」(批 3 复核 N2)。
    if (r.ok && j.requeued) {
      err.value = '';
      notice.value = '执行位已满,这条任务已退回队列排队 —— 坑一空会自动接着跑,不用再点。';
    } else {
      notice.value = '';
      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 openBudgetModal() {
  budgetForm.value = { hours: 8, calls: '', tokens: '', resume: true };
  showBudgetModal.value = true;
}
function submitBudget() {
  const f = budgetForm.value;
  const patch = {};
  if (Number(f.hours) > 0) patch.wallclock_ms = Number(f.hours) * 3600 * 1000;
  if (Number(f.calls) > 0) patch.total_agent_calls = Number(f.calls);
  if (Number(f.tokens) > 0) patch.total_tokens = Number(f.tokens);
  patch.resume = !!f.resume;
  showBudgetModal.value = false;
  act('budget', patch);
}

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 自带重连,不手工重建
  // 具名事件必须逐个 addEventListener(onmessage 只接无 event: 字段的帧),漏一个 = 整类收不到。
  // 之前这里只列了 6 个,node.error / node.triage / node.boundary / node.contract 等
  // **从来没到过浏览器** —— 失败节点「无原因」的根因之一(W12-UI)。名单见 node-signals.js。
  for (const k of RUN_EVENT_KINDS) {
    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; }
.req { font-size:10px; padding:1px 7px; border-radius:9px; background:#fff3cd; color:#856404; border:1px solid #f0dca8; cursor:help; }
.req.back { background:#fff4e5; color:#8a4b00; border-color:#f0d0a8; }
.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; }
.btn.budget { border-color:#8a5cd6; color:#8a5cd6; }
.next-hint { font-size:11px; color:#666; }
.next-hint code { background:#eef4ff; padding:1px 5px; border-radius:3px; }
.notice { color: var(--text-secondary); background: var(--bg-overlay); border: 1px dashed var(--border-primary);
  border-radius: 6px; padding: 6px 8px; font-size: 12px; line-height: 1.5; margin: 6px 0; }
.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; }
.budget-field { display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:12.5px; color:#444; margin-bottom:8px; }
.budget-field input { width:140px; font-size:12.5px; padding:3px 6px; border:1px solid #ddd; border-radius:4px; }
.budget-resume { display:flex; align-items:center; gap:6px; font-size:12.5px; color:#444; margin:4px 0 14px; }
</style>