workbench.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715
  1. import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal, viewChild } from '@angular/core';
  2. import { Router } from '@angular/router';
  3. import {
  4. UiAvatar,
  5. UiButton,
  6. UiCard,
  7. UiDrawer,
  8. UiEmpty,
  9. UiInput,
  10. UiProgress,
  11. UiSkeleton,
  12. UiStatistic,
  13. UiTooltip,
  14. } from '../../../ui';
  15. import { PageComponent } from '../../../components/shell/page/page';
  16. import { InfiniteScrollDirective } from '../../../shared/infinite-scroll.directive';
  17. import { FilterChipsComponent } from '../../../components/domain/filter-chips/filter-chips';
  18. import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
  19. import { Agent } from '../../../core/api/models';
  20. import { openInNewTab } from '../../../core/open-link';
  21. import { AlertService } from '../../../core/alert.service';
  22. import { isReporterSelfTalk, livenessOf } from '../../../core/agent-liveness';
  23. import { DeviceService } from '../../../core/device.service';
  24. import { hasMetrics as hasMetricsFn, fmtBalance, fmtPercent, fmtTokens, isMatrixReady, usageOf } from '../../../core/format';
  25. import { LiveService } from '../../../core/live.service';
  26. import { TasksService } from '../../../core/tasks.service';
  27. import { fmtDuration } from '../../../core/time';
  28. /**
  29. * 桌面·工作台(SPEC FR-07 + FR-07b)
  30. *
  31. * 实现纪律(2026-10-15 返工):
  32. * · **用 ui-kit 组件拼装**,不手写替代品 —— 卡片/统计/进度/抽屉全部走底座
  33. * · **流动布局**:卡片网格 `auto-fill + minmax`,不写死像素;由 `app-page` 管 max-width 与居中
  34. * · **不出现 demo 件**:无演示控制台、无演示提示条、无「演示数据」文案
  35. *
  36. * AC 覆盖:07.1 字段齐全 / 07.2 双端同源 / 07.3 失败显示错误态 / 07.4 余额外链
  37. * 07b.3 阻塞主动上浮 / 07b.6 一期只读
  38. */
  39. @Component({
  40. selector: 'app-dk-workbench-page',
  41. changeDetection: ChangeDetectionStrategy.OnPush,
  42. imports: [
  43. UiAvatar,
  44. UiButton,
  45. UiCard,
  46. UiDrawer,
  47. UiEmpty,
  48. UiInput,
  49. UiProgress,
  50. UiSkeleton,
  51. UiStatistic,
  52. UiTooltip,
  53. InfiniteScrollDirective,
  54. FilterChipsComponent,
  55. PageComponent,
  56. StatusBadgeComponent,
  57. ],
  58. template: `
  59. <app-page [fixedHeader]="true" title="工作台" subtitle="所有数字生命的运行实况与任务进展。">
  60. <!-- 工具条:搜索 + 状态筛选(固定区,不随卡片滚动) -->
  61. <div pageToolbar class="wb-toolbar">
  62. <ui-input
  63. prefixIcon="fa-solid fa-magnifying-glass"
  64. [clearable]="true"
  65. placeholder="搜索数字生命"
  66. [value]="keyword()"
  67. (valueChange)="onSearch($any($event))"
  68. />
  69. <app-filter-chips
  70. ariaLabel="运行状态"
  71. [chips]="statusChips()"
  72. [active]="statusFilter()"
  73. (select)="onStatusSelect($event)"
  74. />
  75. </div>
  76. <ng-container pageActions>
  77. <ui-button
  78. variant="outline"
  79. icon="fa-solid fa-rotate-right"
  80. [loading]="live.state() === 'loading'"
  81. (click)="refresh()"
  82. >
  83. 刷新实况
  84. </ui-button>
  85. <ui-button variant="primary" icon="fa-solid fa-list-check" (click)="openTasks()">
  86. 任务看板
  87. </ui-button>
  88. </ng-container>
  89. @switch (live.state()) {
  90. @case ('loading') {
  91. <div class="overview">
  92. @for (i of [1, 2, 3, 4]; track i) {
  93. <ui-skeleton variant="rect" height="88px" />
  94. }
  95. </div>
  96. <div class="grid">
  97. @for (i of [1, 2, 3, 4, 5, 6]; track i) {
  98. <ui-skeleton variant="rect" height="220px" />
  99. }
  100. </div>
  101. }
  102. @case ('error') {
  103. <!-- AC-07.3:加载失败必须显示错误态,禁止伪装为空态或权限问题 -->
  104. <ui-card>
  105. <ui-empty
  106. icon="fa-solid fa-triangle-exclamation"
  107. title="实况数据加载失败"
  108. [description]="live.error() ?? '请检查网络后重试。'"
  109. />
  110. <div class="empty-action">
  111. <ui-button variant="outline" (click)="refresh()">重新加载</ui-button>
  112. </div>
  113. </ui-card>
  114. }
  115. @case ('empty') {
  116. <ui-card>
  117. <ui-empty
  118. title="暂无数字生命"
  119. description="当前账号下还没有可用的数字生命。"
  120. />
  121. </ui-card>
  122. }
  123. @default {
  124. <!-- 总览统计:底座 ui-statistic,不再手写统计块 -->
  125. <div class="overview">
  126. <ui-statistic title="数字生命" [value]="live.agents().length" icon="fa-solid fa-cube" />
  127. <!--
  128. 2026-11-11 用户裁决:工作台只表达「在线 / 离线」,
  129. 不再显示「运行中」「需要关注」这类运行态汇总。
  130. -->
  131. <ui-statistic
  132. title="在线"
  133. [value]="onlineCount()"
  134. color="success"
  135. icon="fa-solid fa-signal"
  136. />
  137. <ui-statistic
  138. title="离线"
  139. [value]="offlineCount()"
  140. [color]="offlineCount() > 0 ? 'danger' : 'default'"
  141. icon="fa-solid fa-plug-circle-xmark"
  142. />
  143. <ui-statistic
  144. title="阻塞任务"
  145. [value]="blockedTasks().length"
  146. [color]="blockedTasks().length > 0 ? 'danger' : 'default'"
  147. icon="fa-solid fa-ban"
  148. />
  149. </div>
  150. <!-- AC-07b.3:阻塞主动上浮,不藏在详情页 -->
  151. @if (blockedTasks().length > 0) {
  152. <ui-card class="alerts">
  153. <div class="alert-head">
  154. <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
  155. <strong>{{ blockedTasks().length }} 个任务已阻塞</strong>
  156. <ui-button variant="outline" size="sm" (click)="openTasks()">查看原因</ui-button>
  157. </div>
  158. <ul class="alert-list">
  159. @for (t of blockedTasks(); track t.id) {
  160. <li>
  161. <span class="alert-agent">{{ agentName(t.agentId) }}</span>
  162. <span class="alert-title">{{ t.title }}</span>
  163. @if (t.blockedHours) {
  164. <span class="alert-meta">已阻塞 {{ dur(t.blockedHours) }}</span>
  165. }
  166. @if (t.consecutiveFailures) {
  167. <span class="alert-meta">连续失败 {{ t.consecutiveFailures }} 次</span>
  168. }
  169. </li>
  170. }
  171. </ul>
  172. </ui-card>
  173. }
  174. <!-- 实况卡片网格:流动列宽,窗口变宽就多排几列 -->
  175. <div class="grid">
  176. @for (a of visibleAgents(); track a.id) {
  177. <ui-card>
  178. <div class="card-head">
  179. <ui-avatar [name]="a.name" />
  180. <div class="card-title">
  181. <strong class="ellipsis">{{ a.name }}</strong>
  182. <small class="ellipsis">{{ a.specialty || '数字生命' }}</small>
  183. </div>
  184. <app-status-badge [agent]="a" />
  185. </div>
  186. <div class="card-task">
  187. <span class="eyebrow">当前任务</span>
  188. <!--
  189. 2026-11-11 用户裁决:工作台只表达「是否在线」。
  190. 原先是 currentTask 直出,上报器自指时会把
  191. periodic status reporter (xlosi3m46q) 印在卡片上。
  192. -->
  193. <p class="ellipsis">{{ liveTaskOf(a) }}</p>
  194. </div>
  195. @if (hasMetrics(a)) {
  196. <div class="metrics">
  197. <div class="metric">
  198. <span class="metric-label">CPU</span>
  199. <ui-progress [value]="raw(a.host?.cpuPercent)" size="sm" />
  200. <span class="metric-value">{{ pctText(a.host?.cpuPercent) }}</span>
  201. </div>
  202. <div class="metric">
  203. <span class="metric-label">内存</span>
  204. <ui-progress [value]="raw(a.host?.memPercent)" size="sm" />
  205. <span class="metric-value">{{ pctText(a.host?.memPercent) }}</span>
  206. </div>
  207. </div>
  208. <div class="card-foot">
  209. <span class="foot-item">Token <strong>{{ tokens(a) }}</strong></span>
  210. <!--
  211. 余额口径(2026-11-11):这是**主人账号**的共用余额,不是本容器独立额度。
  212. 容器账单挂在主人账号上,同一主人的所有容器显示同一个数 —— 必须标注,避免误读。
  213. -->
  214. <ui-tooltip [content]="balanceTip(a)" position="top">
  215. <ui-button
  216. variant="ghost"
  217. size="sm"
  218. iconRight="fa-solid fa-arrow-up-right-from-square"
  219. (click)="showBalance(a)"
  220. >
  221. <span class="balance-label">账号余额</span> {{ balance(a) }}
  222. </ui-button>
  223. </ui-tooltip>
  224. </div>
  225. } @else if (ready(a)) {
  226. <!--
  227. 已接入但容器**未上报 host 指标**。
  228. 🔴 此前直接落到「尚未接入」或显示 CPU 0%(误导)——
  229. 0% 与「未上报」是两回事,如实区分(不编造数据)。
  230. -->
  231. <p class="not-ready">
  232. <i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i>
  233. 容器已接入但尚未上报运行指标(CPU/内存)。
  234. </p>
  235. }
  236. <div class="card-actions">
  237. <ui-button variant="outline" size="sm" block (click)="openChannel(a)">
  238. 通道详情
  239. </ui-button>
  240. <ui-button variant="primary" size="sm" block (click)="openAgentTasks(a)">
  241. 查看任务
  242. </ui-button>
  243. </div>
  244. </ui-card>
  245. }
  246. </div>
  247. }
  248. }
  249. <!-- 滚到底加载更多(分页) -->
  250. @if (live.state() === 'ready') {
  251. @if (hasMore()) {
  252. <div appInfiniteScroll (reachedBottom)="loadMore()" class="scroll-sentinel">
  253. <ui-button variant="ghost" size="sm" (click)="loadMore()">加载更多</ui-button>
  254. </div>
  255. } @else if (filteredAgents().length > 0) {
  256. <p class="list-end">已加载全部 {{ filteredAgents().length }} 个数字生命</p>
  257. }
  258. }
  259. </app-page>
  260. <!-- 通道详情:底座 ui-drawer(内置遮罩/Esc/焦点陷阱) -->
  261. <ui-drawer
  262. [open]="!!channelAgent()"
  263. title="通道详情"
  264. placement="right"
  265. (openChange)="channelAgent.set(null)"
  266. >
  267. @if (channelAgent(); as a) {
  268. <div class="drawer-block">
  269. <ui-avatar [name]="a.name" size="lg" />
  270. <div>
  271. <strong>{{ a.name }}</strong>
  272. <p class="drawer-sub">{{ a.specialty }}</p>
  273. </div>
  274. </div>
  275. <dl class="kv">
  276. <div><dt>连接状态</dt><dd>{{ ready(a) ? '已接入' : '未接入' }}</dd></div>
  277. <div><dt>通道类型</dt><dd>{{ a.channels?.matrix ? '在线通道' : '—' }}</dd></div>
  278. <div><dt>接入状态</dt><dd class="ellipsis">{{ ready(a) ? '正常' : '未接入' }}</dd></div>
  279. <div><dt>最近心跳</dt><dd>{{ a.heartbeatAt || '—' }}</dd></div>
  280. </dl>
  281. <p class="drawer-note">此处仅展示连接信息,不展示任何凭证。</p>
  282. }
  283. </ui-drawer>
  284. `,
  285. styles: [
  286. `
  287. /* 编排样式:仅放底座未覆盖的布局,视觉一律交给令牌与底座 */
  288. :host {
  289. display: block;
  290. }
  291. /* 统计区:流动列宽 —— 窗口变宽自然增加列数,不写死 px */
  292. .overview {
  293. display: grid;
  294. grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  295. gap: var(--sp-3);
  296. margin-bottom: var(--sp-5);
  297. }
  298. /* 卡片网格:同上,minmax 决定断点,无需媒体查询 */
  299. .wb-toolbar {
  300. display: flex;
  301. align-items: center;
  302. gap: var(--sp-3);
  303. flex-wrap: wrap;
  304. margin-bottom: var(--sp-4);
  305. }
  306. .scroll-sentinel {
  307. display: flex;
  308. justify-content: center;
  309. padding: var(--sp-4) 0;
  310. }
  311. .list-end {
  312. margin: var(--sp-4) 0 0;
  313. text-align: center;
  314. font-size: var(--fs-12);
  315. color: var(--muted);
  316. }
  317. .grid {
  318. display: grid;
  319. grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  320. gap: var(--sp-4);
  321. }
  322. .alerts {
  323. display: block;
  324. margin-bottom: var(--sp-5);
  325. border-color: var(--danger);
  326. }
  327. .alert-head {
  328. display: flex;
  329. align-items: center;
  330. gap: var(--sp-2);
  331. color: var(--danger);
  332. margin-bottom: var(--sp-2);
  333. }
  334. .alert-head strong {
  335. flex: 1;
  336. min-width: 0;
  337. font-size: var(--fs-13);
  338. }
  339. .alert-list {
  340. list-style: none;
  341. margin: 0;
  342. padding: 0;
  343. display: grid;
  344. gap: var(--sp-1);
  345. }
  346. .alert-list li {
  347. display: flex;
  348. flex-wrap: wrap;
  349. align-items: center;
  350. gap: var(--sp-2);
  351. font-size: var(--fs-12);
  352. color: var(--danger);
  353. }
  354. .alert-agent {
  355. font-weight: 600;
  356. }
  357. .alert-title {
  358. opacity: 0.9;
  359. }
  360. .alert-meta {
  361. font-size: var(--fs-11);
  362. opacity: 0.75;
  363. }
  364. .card-head {
  365. display: flex;
  366. align-items: center;
  367. gap: var(--sp-3);
  368. margin-bottom: var(--sp-3);
  369. }
  370. .card-title {
  371. display: grid;
  372. gap: 2px;
  373. flex: 1;
  374. min-width: 0;
  375. }
  376. .card-title strong {
  377. font-size: var(--fs-15);
  378. color: var(--ink);
  379. }
  380. .card-title small {
  381. font-size: var(--fs-12);
  382. color: var(--muted);
  383. }
  384. .card-task {
  385. padding: var(--sp-3);
  386. border-radius: var(--r-sm);
  387. background: var(--surface-2);
  388. margin-bottom: var(--sp-3);
  389. }
  390. .eyebrow {
  391. font-family: var(--mono, monospace);
  392. font-size: var(--fs-11);
  393. letter-spacing: 1.2px;
  394. color: var(--muted);
  395. text-transform: uppercase;
  396. }
  397. .card-task p {
  398. margin: var(--sp-1) 0 0;
  399. font-size: var(--fs-13);
  400. color: var(--ink);
  401. }
  402. .metrics {
  403. display: grid;
  404. gap: var(--sp-2);
  405. margin-bottom: var(--sp-3);
  406. }
  407. .metric {
  408. display: grid;
  409. grid-template-columns: 32px minmax(0, 1fr) 44px;
  410. align-items: center;
  411. gap: var(--sp-2);
  412. }
  413. .metric-label {
  414. font-size: var(--fs-11);
  415. color: var(--muted);
  416. }
  417. .metric-value {
  418. font-size: var(--fs-11);
  419. color: var(--ink-2);
  420. text-align: right;
  421. }
  422. .card-foot {
  423. display: flex;
  424. align-items: center;
  425. justify-content: space-between;
  426. gap: var(--sp-2);
  427. margin-bottom: var(--sp-2);
  428. }
  429. .foot-item {
  430. font-size: var(--fs-11);
  431. color: var(--muted);
  432. }
  433. .foot-item strong {
  434. color: var(--ink-2);
  435. }
  436. .not-ready {
  437. display: flex;
  438. align-items: center;
  439. gap: var(--sp-2);
  440. margin: 0 0 var(--sp-3);
  441. font-size: var(--fs-12);
  442. color: var(--muted);
  443. }
  444. .card-actions {
  445. display: grid;
  446. grid-template-columns: repeat(2, minmax(0, 1fr));
  447. gap: var(--sp-2);
  448. }
  449. .empty-action {
  450. display: flex;
  451. justify-content: center;
  452. margin-top: var(--sp-3);
  453. }
  454. .drawer-block {
  455. display: flex;
  456. align-items: center;
  457. gap: var(--sp-3);
  458. margin-bottom: var(--sp-4);
  459. }
  460. .drawer-sub {
  461. margin: 2px 0 0;
  462. font-size: var(--fs-12);
  463. color: var(--muted);
  464. }
  465. .kv {
  466. margin: 0;
  467. display: grid;
  468. gap: var(--sp-2);
  469. }
  470. .kv > div {
  471. display: flex;
  472. justify-content: space-between;
  473. gap: var(--sp-3);
  474. font-size: var(--fs-13);
  475. }
  476. .kv dt {
  477. color: var(--muted);
  478. flex-shrink: 0;
  479. }
  480. .kv dd {
  481. margin: 0;
  482. color: var(--ink);
  483. text-align: right;
  484. min-width: 0;
  485. }
  486. .drawer-note {
  487. margin: var(--sp-4) 0 0;
  488. font-size: var(--fs-11);
  489. color: var(--muted);
  490. }
  491. .ellipsis {
  492. overflow: hidden;
  493. text-overflow: ellipsis;
  494. white-space: nowrap;
  495. }
  496. `,
  497. ],
  498. })
  499. export class DkWorkbenchPage implements OnInit {
  500. /**
  501. * Fmode Studio 充值页(2026-11-11)。
  502. *
  503. * 与云函数 `xlosi3m46q` 取余额的 `billing/query` 同源,
  504. * 所以这里显示的金额和点进去看到的余额是同一口径。
  505. */
  506. private static readonly BALANCE_URL = 'https://app.fmode.cn/dev/studio/balance';
  507. readonly live = inject(LiveService);
  508. readonly tasks = inject(TasksService);
  509. private readonly router = inject(Router);
  510. private readonly device = inject(DeviceService);
  511. private readonly alert = inject(AlertService);
  512. readonly channelAgent = signal<Agent | null>(null);
  513. readonly blockedTasks = computed(() => this.tasks.blocked());
  514. /**
  515. * 在线容器数(2026-11-11 用户裁决:工作台只显示「在线 / 离线」)。
  516. *
  517. * 判据统一走 `livenessOf()`(心跳 80 分钟 + Matrix 通道),
  518. * 不再用 `resolveAgentState` 的「执行中 / 空闲」这套运行态。
  519. */
  520. readonly onlineCount = computed(
  521. () => this.live.agents().filter((a) => livenessOf(a, Date.now(), this.live.fetchedAt()).online).length,
  522. );
  523. /** 离线容器数(总数 − 在线) */
  524. readonly offlineCount = computed(() => this.live.agents().length - this.onlineCount());
  525. /**
  526. * 卡片上「当前任务」的文案(2026-11-11)。
  527. *
  528. * 容器上报器会把 `status: running` + `currentTask: "periodic status reporter"`
  529. * 硬编码上报,直接展示等于告诉用户「它在干活」,其实什么业务都没做。
  530. * 这里只认**真实业务任务**。
  531. */
  532. readonly liveTaskOf = (a: Agent): string => {
  533. if (a.status !== 'running' || isReporterSelfTalk(a)) return '暂无进行中的任务';
  534. return a.currentTask?.trim() || a.currentAction?.trim() || '暂无进行中的任务';
  535. };
  536. async ngOnInit(): Promise<void> {
  537. await this.live.ensureLoaded();
  538. await this.tasks.ensureAllLoaded();
  539. }
  540. /*
  541. * 工作台列表:搜索 + 状态筛选 + 分页(用户要求统一为「搜索+参数筛选+分页」)。
  542. */
  543. readonly keyword = signal('');
  544. /** 2026-11-11:筛选收成两态(在线 / 离线) */
  545. readonly statusFilter = signal<'all' | 'online' | 'offline'>('all');
  546. private readonly PAGE_SIZE = 12;
  547. private readonly shown = signal(this.PAGE_SIZE);
  548. private readonly infiniteScroll = viewChild(InfiniteScrollDirective);
  549. /** 筛选后的全集(未分页) */
  550. readonly filteredAgents = computed(() => {
  551. const q = this.keyword().trim().toLowerCase();
  552. const f = this.statusFilter();
  553. const fetchedAt = this.live.fetchedAt();
  554. return this.live.agents().filter((a) => {
  555. /* 判据统一走 livenessOf(心跳 80 分钟 + 通道),与徽标同源 */
  556. const online = livenessOf(a, Date.now(), fetchedAt).online;
  557. if (f === 'online' && !online) return false;
  558. if (f === 'offline' && online) return false;
  559. if (!q) return true;
  560. return (
  561. a.name.toLowerCase().includes(q) || (a.specialty ?? '').toLowerCase().includes(q)
  562. );
  563. });
  564. });
  565. /** 分页后可见的数字生命 */
  566. readonly visibleAgents = computed(() => this.filteredAgents().slice(0, this.shown()));
  567. readonly hasMore = computed(() => this.shown() < this.filteredAgents().length);
  568. readonly statusChips = computed(() => [
  569. { key: 'all', label: '全部', count: this.live.agents().length },
  570. { key: 'online', label: '在线', count: this.onlineCount() },
  571. { key: 'offline', label: '离线', count: this.offlineCount() },
  572. ]);
  573. onSearch(v: string): void {
  574. this.keyword.set(v);
  575. this.resetPaging();
  576. }
  577. onStatusSelect(key: string): void {
  578. this.statusFilter.set(key as 'all' | 'online' | 'offline');
  579. this.resetPaging();
  580. }
  581. loadMore(): void {
  582. if (!this.hasMore()) return;
  583. this.shown.update((n) => n + this.PAGE_SIZE);
  584. this.infiniteScroll()?.reset();
  585. }
  586. resetPaging(): void {
  587. this.shown.set(this.PAGE_SIZE);
  588. }
  589. async refresh(): Promise<void> {
  590. await this.live.refresh();
  591. }
  592. ready(a: Agent): boolean {
  593. return isMatrixReady(a);
  594. }
  595. /** 是否有可展示的运行指标(已接入 **且** 容器上报过 host);模板判定用 */
  596. hasMetrics(a: Agent): boolean {
  597. return hasMetricsFn(a);
  598. }
  599. /** 进度条需要 0–100 的数值;未接入或无数据时归零 */
  600. raw(v?: number): number {
  601. return Math.min(100, Math.max(0, v ?? 0));
  602. }
  603. pctText(v?: number): string {
  604. return fmtPercent(v);
  605. }
  606. tokens(a: Agent): string {
  607. return fmtTokens(usageOf(a).totalTokens);
  608. }
  609. balance(a: Agent): string {
  610. return fmtBalance(a.balance);
  611. }
  612. /** 余额悬浮说明:讲清「谁的余额 / 已用多少」 */
  613. balanceTip(a: Agent): string {
  614. const used = a.balance?.usedCny;
  615. const usedText = used == null ? '' : `;已用 ${fmtBalance({ remainCny: used })}`;
  616. const owner = a.balance?.sharedAccount ? '主人账号' : '账号';
  617. return `${owner}共用余额(容器账单挂在主人账号,非本容器独立额度)${usedText}。点击前往充值`;
  618. }
  619. dur(h?: number): string {
  620. return fmtDuration(h);
  621. }
  622. agentName(id: string): string {
  623. return this.live.agentById(id)?.name ?? '数字生命';
  624. }
  625. /**
  626. * 余额 → 充值页(2026-11-11 修复)。
  627. *
  628. * 🔴 原来只在 `a.balance?.pageUrl` 有值时跳转,而云函数 `xlosi3m46q`
  629. * **根本不返回 `pageUrl`**(只返回 remainCny/usedCny/source)——
  630. * 于是点击余额调用走到 else 分支什么都不做,用户看到的就是「点了没反应」。
  631. *
  632. * 现在:有 `pageUrl` 就用它(后端将来若下发,优先尊重);
  633. * 没有就回落到 Fmode Studio 充值页(与云函数取余额的 `billing/query` 同源)。
  634. */
  635. showBalance(a: Agent): void {
  636. const url = a.balance?.pageUrl || DkWorkbenchPage.BALANCE_URL;
  637. openInNewTab(url);
  638. }
  639. openChannel(a: Agent): void {
  640. this.channelAgent.set(a);
  641. }
  642. /**
  643. * 任务入口(2026-11-11 改)。
  644. *
  645. * 「任务」页路由已随用户裁决下线(入口和路由一起关),原来跳
  646. * `/desktop/tasks`、`/desktop/tasks/:id` 会落到会话页,看起来像点坏了。
  647. * 工作台上仍保留这两个按钮,但改为**如实告知**:不假装还能跳过去。
  648. */
  649. openTasks(): void {
  650. this.alert.show('任务看板暂未开放', '任务入口已下线,可在会话右栏「任务」页签查看该容器的全部任务。', 'warning');
  651. }
  652. openAgentTasks(a: Agent): void {
  653. this.alert.show(
  654. `「${a.name}」的任务`,
  655. '任务看板暂未开放,进入该容器的会话后,可在右栏「任务」页签查看。',
  656. 'warning',
  657. );
  658. }
  659. }