console: 任务列表行显示首字、总耗时、token 栏位

任务列表每一行新增三项(筛选、搜索、选中高亮、状态点不动):
- 首字:行前小方块,prompt 第一个非空白字符(按码点取),
  无 prompt 时用任务 id 首字符兜底;原 prompt 文案不动。
- 总耗时:只用列表已回时间戳算。终态用 finished_at -
  started_at(与服务端 avg_duration_seconds 同口径);running
  显示“进行中 + 已用时长”,随列表刷新更新;无 started_at
  时用 created_at 兜底(含排队,title 注明口径)。格式:
  3分12秒、1小时5分、1天1小时。
- token:栏位已接好,total_tokens 优先,actual +
  estimated 分项加总次之;但列表接口 _task_to_dict 目前
  不回任何 token 字段,单任务真数只在 GET /tasks/{id}/cost
  里,所以当前显示“token —”,不编造、不逐个调 /cost
  拖慢列表。服务端在列表里补 total_tokens 后本行自动出真数。

诚实边界:worker 每次认领重写 started_at,多轮续跑任务
的耗时是末次认领时长,不是历次之和;严格累计需服务端
保留首次 started_at 或累加各轮时长。token 的服务端缺口:
需在 _task_to_dict / 列表查询里按 task_id 聚合 cost_records
的 total_tokens,不需要新端点。
构建:npm run build(tsc + vite singlefile)0 退出,
dist/index.html 原样拷到 cloud/console.html,产物含
issue-first、耗时、token —,未动其它确认框。
This commit is contained in:
host committed 2026-10-29 18:24:07 +08:00
1 parent e5bf361c6d
commit 65a875ada9
4 files changed
+132 -25

No files matched your search

@@ -1,5 +1,94 @@
import { useConsole } from "../context";
import { shortId } from "../api/client";
import type { Task } from "../api/types";
// 任务列表行 meta:首字 / 总耗时 / token。
//
// 首字:prompt 第一个非空白字符(按码点取,emoji 不截半);
// prompt 为空或全空白时用任务 id 首字符兜底。只作行前标识,
// 不改动原 prompt 文案。
//
// 总耗时:只用列表接口已回的时间戳算,绝不编造。
// 终态且有 started_at + finished_at:finished_at - started_at
// (与服务端项目统计 avg_duration_seconds 同口径);
// running 且有 started_at:现在 - started_at,标“进行中”,
// 数值随列表刷新更新,不是实时 tick;
// 无 started_at 的终态:finished_at - created_at 兜底
// (含排队等待,口径见 title);
// 无 started_at 的 queued/running:现在 - created_at 兜底。
// 诚实边界:worker 每次认领都会重写 started_at,所以多轮续跑
// 任务这里显示的是末次认领的时长,不是历次认领之和;要严格
// 累计总时长,服务端需保留首次 started_at 或累加各轮时长,
// 那是服务端字段缺口,不是前端能算出来的。
//
// token:列表接口(_task_to_dict)目前不回任何 token 字段,
// 单任务成本在 GET /tasks/{id}/cost 里(total_tokens、
// actual_tokens、estimated_tokens)。列表行不逐个调 /cost:
// 一屏 N 个任务就是 N 个额外请求,会把列表刷新拖慢、还容易
// 撞限流。所以这里只留栏位:服务端一旦在列表里补
// total_tokens 就显示真数;分项只有 actual/estimated 时加总
// 显示;都没有时显示“token —”,不编造、不估算。
// 服务端补字段的最小改法:_task_to_dict 里给每行附
// cost_records 按 task_id 求和的 total_tokens(或在列表查询
// 里 LEFT JOIN 聚合一次),不需要新端点。
function firstChar(t: Task): string {
const p = (t.prompt || "").trim();
if (p) return [...p][0];
return t.id ? [...t.id][0] : "?";
}
function formatDuration(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
if (s < 60) return s + "秒";
if (s < 3600) {
const m = Math.floor(s / 60), r = s % 60;
return r ? m + "分" + r + "秒" : m + "分";
}
if (s < 86400) {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
return m ? h + "小时" + m + "分" : h + "小时";
}
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
return h ? d + "天" + h + "小时" : d + "天";
}
function num(v: number | null | undefined): number | null {
if (v === null || v === undefined) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
// 返回 [显示文案, title 口径说明];算不出时文案为“—”。
function durationOf(t: Task): [string, string] {
const now = Date.now() / 1000;
const started = num(t.started_at), finished = num(t.finished_at);
const created = num(t.created_at);
if (started !== null && finished !== null && finished >= started) {
return [formatDuration(finished - started), "总耗时:开始→结束(末次认领的执行时长)"];
}
if (started !== null && t.status === "running") {
return ["进行中 " + formatDuration(now - started), "总耗时:进行中,已用时长从开始算起,随列表刷新更新"];
}
if (created !== null && finished !== null && finished >= created) {
return [formatDuration(finished - created), "总耗时:创建→完成(含排队等待;无开始时间时的兜底口径)"];
}
if (created !== null && (t.status === "queued" || t.status === "running")) {
return [(t.status === "queued" ? "排队中 " : "进行中 ") + formatDuration(now - created), "总耗时:从创建算起的已用时长(无开始时间时的兜底口径)"];
}
return ["—", "总耗时:无可用时间戳"];
}
function tokenOf(t: Task): [string, string] {
const total = num(t.total_tokens);
if (total !== null) {
return [total.toLocaleString() + " token", "token:服务端列表 total_tokens"];
}
const actual = num(t.actual_tokens), estimated = num(t.estimated_tokens);
if (actual !== null || estimated !== null) {
return [((actual || 0) + (estimated || 0)).toLocaleString() + " token", "token:actual + estimated 分项加总"];
}
return ["token —", "token:列表接口未返回 token 字段(服务端需在任务列表里补 total_tokens);单任务真数在任务详情的成本里可查,本行不逐个调成本接口、不编造"];
}
// GitHub issues-style task list: search on top, the project selector
// (the auto-project fix — do not remove it), a 主要聊天 header with a
@@ -46,19 +135,27 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
: ctx.filteredTasks.length === 0 ? <div className="muted">{ctx.tasks.length ? "没有符合筛选条件的任务" : "该项目暂无任务"}</div>
: (
<ul className="issue-list">
{ctx.filteredTasks.slice().reverse().map((t) => (
{ctx.filteredTasks.slice().reverse().map((t) => {
const [dur, durTitle] = durationOf(t);
const [tok, tokTitle] = tokenOf(t);
const fc = firstChar(t);
return (
<li key={t.id} className={"issue-item" + (ctx.currentTask?.id === t.id ? " selected" : "")} onClick={() => ctx.showDetail(t.id)}>
<span className={"issue-state st-" + t.status} aria-hidden="true" />
<span>
<span className="issue-first" title={"任务首字:" + fc} aria-hidden="true">{fc}</span>
<span style={{ flex: 1, minWidth: 0 }}>
<div className="issue-title">
{t.plan ? "[计划] " : ""}{t.forked_from ? "[分支] " : ""}{t.coordinator_parts ? "[协调] " : ""}{t.parent_task_id ? "[协调子任务] " : ""}{String(t.prompt || shortId(t.id)).slice(0, 60)}
</div>
<div className="issue-meta">
{shortId(t.id)} · <span className={"st-" + t.status}>{t.status}</span> · {t.executor || ""}{t.updated_at ? " · " + new Date(t.updated_at * 1000).toLocaleString() : ""}
{" · "}<span title={durTitle}>耗时 {dur}</span>
{" · "}<span title={tokTitle}>{tok}</span>
</div>
</span>
</li>
))}
);
})}
</ul>
)}
</div>