console: 任务详情也显示首字、总耗时、token
列表行与详情共用同一口径:新抽 api/taskMeta.ts(首字/
总耗时/token 计算),TaskListSection 改 import 它(逻辑
原样不动),TaskDetailPanel 在状态行下方加一行:首字
方块 + 耗时 + token,title 里写口径。
详情的数据缺口一并补上:单任务接口 _get_task 此前走
_task_to_dict 默认 total_tokens=None,详情 token 只能
显示占位;现与列表一样用 _task_tokens_map 一次聚合
(单任务只多一条查询),详情直接出真数。
types.ts 里过期的“列表不回 token”注释同步更新。
新测试:单任务 GET /tasks/{id} 聚合回 total_tokens。
This commit is contained in:
1 parent
3696dad7c8
commit
0f7162934b
7 files changed
+127
-99
No files matched your search
@@ -0,0 +1,83 @@
|
||||
import type { Task } from "./types";
|
||||
|
||||
// 任务 meta 计算,列表行与详情共用,口径必须一致,改一处两处同变。
|
||||
//
|
||||
// 首字: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:优先 total_tokens(列表接口由服务端一次聚合回,
|
||||
// 单任务接口同样聚合回);分项只有 actual/estimated 时加总
|
||||
// 显示;都没有时显示“token —”,不编造、不估算、不逐个调
|
||||
// /cost(列表一屏 N 个任务逐个调会拖慢刷新、还容易撞限流)。
|
||||
export function firstChar(t: Task): string {
|
||||
const p = (t.prompt || "").trim();
|
||||
if (p) return [...p][0];
|
||||
return t.id ? [...t.id][0] : "?";
|
||||
}
|
||||
|
||||
export 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 口径说明];算不出时文案为“—”。
|
||||
export 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 ["—", "总耗时:无可用时间戳"];
|
||||
}
|
||||
|
||||
export 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 字段;单任务真数在详情的成本里可查,不逐个调成本接口、不编造"];
|
||||
}
|
||||
@@ -11,9 +11,9 @@ export interface Task {
|
||||
// (秒,epoch),此前 Task 类型没列、前端直接忽略了。
|
||||
created_at?: number; started_at?: number | null; finished_at?: number | null;
|
||||
last_heartbeat?: number | null;
|
||||
// token:列表接口目前不回这些字段(见 TaskListSection 的注释),
|
||||
// 先留类型位:服务端一旦在列表里补 total_tokens(或
|
||||
// actual/estimated 分项),列表行无需再改代码就能显示真数。
|
||||
// token:列表与单任务接口由服务端一次聚合回 total_tokens
|
||||
// (见 api/taskMeta.ts);actual/estimated 分项为成本接口
|
||||
// 的备用口径。
|
||||
total_tokens?: number | null; actual_tokens?: number | null;
|
||||
estimated_tokens?: number | null; unpriced_actual_tokens?: number | null;
|
||||
turn_id?: string; root_turn_id?: string; plan?: number | boolean; forked_from?: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useConsole } from "../context";
|
||||
import { Card } from "../components/ui";
|
||||
import { shortId, fmt } from "../api/client";
|
||||
import { STATUS_EXPLAIN } from "../api/types";
|
||||
import { firstChar, durationOf, tokenOf } from "../api/taskMeta";
|
||||
import DetailActions from "./detail/DetailActions";
|
||||
import ReviewPanel from "./detail/ReviewPanel";
|
||||
import { CoordPanel, AgentsPanel, ApprovalPanel, PeerPanel, ThreadPanel } from "./detail/DetailLists";
|
||||
@@ -13,10 +14,14 @@ export default function TaskDetailPanel() {
|
||||
const ctx = useConsole();
|
||||
const t = ctx.currentTask;
|
||||
if (!t) return <Card id="nav-detail" title="任务详情"><div className="muted">点任务行查看详情</div></Card>;
|
||||
const [dur, durTitle] = durationOf(t);
|
||||
const [tok, tokTitle] = tokenOf(t);
|
||||
const fc = firstChar(t);
|
||||
return (
|
||||
<Card id="nav-detail" title="任务详情">
|
||||
<div>
|
||||
状态 <b className={"st-" + t.status}>{t.status}</b>({STATUS_EXPLAIN[t.status] || "未知状态"})
|
||||
<br /><span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}><span className="issue-first" title={"任务首字:" + fc} aria-hidden="true">{fc}</span><span title={durTitle}>耗时 {dur}</span><span> · </span><span title={tokTitle}>{tok}</span></span>
|
||||
<br />任务 {t.id} | turn {shortId(t.turn_id)} | root turn {shortId(t.root_turn_id)}
|
||||
<br />executor {t.executor || ""} | 主机 {t.worktree_host || ""}
|
||||
<br />模型 {t.model || "默认"} | 强度 {t.effort || "默认"}
|
||||
|
||||
@@ -1,90 +1,9 @@
|
||||
import { useConsole } from "../context";
|
||||
import { shortId } from "../api/client";
|
||||
import type { Task } from "../api/types";
|
||||
import { firstChar, durationOf, tokenOf } from "../api/taskMeta";
|
||||
|
||||
// 任务列表行 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:列表接口一次聚合回 total_tokens(服务端
|
||||
// _task_tokens_map:cost_records 按 task_id 求和,一屏只多
|
||||
// 一条查询)。列表行不逐个调 /cost:逐个调是一屏 N 个额外
|
||||
// 请求,会把列表刷新拖慢、还容易撞限流。分项只有
|
||||
// actual/estimated 时加总显示;都没有(非列表调用方、
|
||||
// total_tokens 为 null)时显示“token —”,不编造、不估算。
|
||||
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 字段;单任务真数在任务详情的成本里可查,本行不逐个调成本接口、不编造"];
|
||||
}
|
||||
// 任务列表行 meta:首字 / 总耗时 / token,计算见 api/taskMeta.ts,
|
||||
// 与任务详情共用同一口径。
|
||||
|
||||
// GitHub issues-style task list: search on top, the project selector
|
||||
// (the auto-project fix — do not remove it), a 主要聊天 header with a
|
||||
|
||||
Reference in new issue
Block a user