console: 任务详情自动显示成本明细

打开详情就自动拉 GET /tasks/{id}/cost(不再只靠
“查成本”按钮出 JSON),详情顶部新一行:美元、总
token(实际/估算、缓存、无价单列)、轮数、fallback
次数;美元小于 1 美分时用 4 位小数,避免小任务
显示成 $0.00 误导成免费;加载中/失败有各自的
弱化文案,成本拉失败不影响详情其它内容;原
“查成本”按钮不动,仍可手动拉原始 JSON。
服务端不用改:成本接口与 _get_task 的 token 聚合
都已在 #150 / 详情 meta 那单里就位。
This commit is contained in:
host committed 2026-10-30 00:39:13 +08:00
1 parent 0f7162934b
commit 994c4ea5d1
7 files changed
+57 -31

No files matched your search

+9
View File
@@ -70,6 +70,15 @@ export function durationOf(t: Task): [string, string] {
return ["—", "总耗时:无可用时间戳"];
}
// 成本美元:usd_micros 是微美元(1 美元 = 1,000,000)。
// 小于 1 美分时用 4 位小数,否则 2 位——小任务直接 toFixed(2)
// 会把 $0.003 显示成 $0.00,误导成免费。
export function fmtCostUsd(micros: number | null | undefined): string {
if (micros === null || micros === undefined || !Number.isFinite(Number(micros))) return "—";
const v = Number(micros) / 1000000;
return "$" + (v > 0 && v < 0.01 ? v.toFixed(4) : v.toFixed(2));
}
export function tokenOf(t: Task): [string, string] {
const total = num(t.total_tokens);
if (total !== null) {
+1
View File
@@ -24,6 +24,7 @@ export interface Task {
child_model?: string; child_effort?: string; approval?: string;
result_text?: string; error_text?: string;
}
export interface TaskCost { task_id?: string; project_id?: string; total_tokens?: number; usd_micros?: number; turns?: number; fallback_count?: number; actual_tokens?: number; estimated_tokens?: number; cached_input_tokens?: number; unpriced_actual_tokens?: number }
export interface Review { id: string; target_type?: string; branch?: string; sha?: string; instructions?: string; status?: string; created_at?: number; findings?: string }
export interface Approval { id: string; kind?: string; status?: string; summary?: string; decision?: string; decided_by?: string; decided_at?: number }
export interface Followup { mode?: string; status?: string; sender_task_id?: string; body?: string }
+2 -2
View File
@@ -1,5 +1,5 @@
import { createContext, useContext } from "react";
import type { Automation, Env, Member, Project, Review, Approval, Followup, PeerMessage, Task, ThreadInfo, Triage, Worker, SkillItem, McpServer, Agent, DispatchBody, ReviewBody, TraceEntry, ChatHistory } from "./api/types";
import type { Automation, Env, Member, Project, Review, Approval, Followup, PeerMessage, Task, TaskCost, ThreadInfo, Triage, Worker, SkillItem, McpServer, Agent, DispatchBody, ReviewBody, TraceEntry, ChatHistory } from "./api/types";
export interface ConsoleCtx {
keySaved: boolean; keyState: string;
@@ -23,7 +23,7 @@ export interface ConsoleCtx {
statusFilter: string; setStatusFilter: (v: string) => void; executorFilter: string; setExecutorFilter: (v: string) => void; searchText: string; setSearchText: (v: string) => void;
filteredTasks: Task[];
currentTask: Task | null; detailBody: string; actionMsg: string; actionReasons: string;
showDetail: (id: string) => void; doAction: (kind: string) => void; taskCost: () => void; costBox: string;
showDetail: (id: string) => void; doAction: (kind: string) => void; taskCost: () => void; costBox: string; costSummary: TaskCost | null | undefined;
forkVisible: boolean; setForkVisible: (v: boolean) => void; forkTask: (msg: string) => Promise<void>; forkMsg: string;
// Continue input is ContinuePanel (ChatInput); its send path is sendChatMessage below.
coordChildren: Task[]; coordText: string; agents: Agent[]; agentsNote: string;
@@ -130,7 +130,7 @@ export function useConsoleStore(): ConsoleCtx {
filteredTasks: taskList.filteredTasks,
currentTask: detail.currentTask, detailBody: detail.detailBody, actionMsg: detail.actionMsg, actionReasons: detail.actionReasons,
showDetail: (id: string) => void detail.showDetail(id),
doAction: detail.doAction, taskCost: detail.taskCost, costBox: detail.costBox,
doAction: detail.doAction, taskCost: detail.taskCost, costBox: detail.costBox, costSummary: detail.costSummary,
forkVisible: detail.forkVisible, setForkVisible: detail.setForkVisible, forkTask: detail.forkTask, forkMsg: detail.forkMsg,
coordChildren: detail.coordChildren, coordText: "", agents: detail.agents, agentsNote: detail.agentsNote,
reviews: detail.reviews, reviewMsg: detail.reviewMsg, reviewError: detail.reviewError,
+17 -4
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { api, getKey } from "../api/client";
import { paths } from "../api/paths";
import { TRACE_TERMINAL } from "../api/types";
import type { Agent, Approval, ChatHistory, Followup, PeerMessage, Review, Task, ThreadInfo, TraceEntry } from "../api/types";
import type { Agent, Approval, ChatHistory, Followup, PeerMessage, Review, Task, TaskCost, ThreadInfo, TraceEntry } from "../api/types";
// Task detail state: the selected task, its trace SSE/one-shot stream,
// and every structured sub-list. Chat sending lives here too:
@@ -13,6 +13,9 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
const [actionMsg, setActionMsg] = useState("");
const [actionReasons, setActionReasons] = useState("");
const [costBox, setCostBox] = useState("");
// 成本明细:打开详情就自动加载(不再只靠“查成本”按钮出 JSON)。
// undefined = 加载中,null = 加载失败,对象 = 已加载。
const [costSummary, setCostSummary] = useState<TaskCost | null | undefined>(undefined);
const [forkVisible, setForkVisible] = useState(false);
const [forkMsg, setForkMsg] = useState("");
const [coordChildren, setCoordChildren] = useState<Task[]>([]);
@@ -68,6 +71,13 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
} catch { setCoordChildren([]); }
}, []);
const loadCost = useCallback(async (taskId: string) => {
try {
const c = await api<TaskCost>(paths.taskCost(taskId));
setCostSummary(c);
} catch { setCostSummary(null); }
}, []);
const loadFollowups = useCallback(async (taskId: string) => {
try { const d = await api<{ followups: Followup[] }>(paths.taskFollowups(taskId)); setFollowups(d.followups || []); }
catch { setFollowups([]); }
@@ -215,6 +225,8 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
if (!body) body = "(暂无 result/error 输出)";
setDetailBody(body);
setCostBox("");
setCostSummary(undefined);
void loadCost(taskId);
setForkVisible(false);
setForkMsg("");
setPeerMsg("");
@@ -247,7 +259,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
setCurrentTask(null);
setDetailBody("加载失败:" + e.message);
}
}, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadChatHistory, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, getRole]);
}, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadChatHistory, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, loadCost, getRole]);
const refreshDetailLists = useCallback(async () => {
const t = currentTaskRef.current;
@@ -269,7 +281,8 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
void loadPeerMessages(t.id);
void loadThread(t.id);
void loadThreads(t.id);
}, [loadFollowups, loadChatHistory, loadReviews, loadApprovals, loadCoordChildren, loadAgents, loadPeerMessages, loadThread, loadThreads]);
void loadCost(t.id);
}, [loadFollowups, loadChatHistory, loadReviews, loadApprovals, loadCoordChildren, loadAgents, loadPeerMessages, loadThread, loadThreads, loadCost]);
const doAction = useCallback(async (kind: string) => {
const t = currentTaskRef.current;
@@ -409,7 +422,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
useEffect(() => () => stopTrace(), [stopTrace]);
return {
currentTask, detailBody, actionMsg, actionReasons, costBox, forkVisible, setForkVisible, forkMsg,
currentTask, detailBody, actionMsg, actionReasons, costBox, costSummary, forkVisible, setForkVisible, forkMsg,
coordChildren, agents, agentsNote, reviews, reviewMsg, reviewError, traceEntries, traceNote, traceDone,
traceKind, setTraceKind, traceMsg, followups, approvals, approvalMsg, peerMessages, peerMsg,
threadText, threadGateNote, threadMsg, threadList, chatMsg, chatHistory,
@@ -2,7 +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 { firstChar, durationOf, tokenOf, fmtCostUsd } from "../api/taskMeta";
import DetailActions from "./detail/DetailActions";
import ReviewPanel from "./detail/ReviewPanel";
import { CoordPanel, AgentsPanel, ApprovalPanel, PeerPanel, ThreadPanel } from "./detail/DetailLists";
@@ -22,6 +22,9 @@ export default function TaskDetailPanel() {
<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 />{ctx.costSummary === undefined ? <span className="muted">成本加载中…</span>
: ctx.costSummary === null ? <span className="muted">成本明细加载失败(“查成本”按钮仍可手动拉取原始 JSON)</span>
: <span title="成本明细:GET /tasks/{id}/cost 聚合;美元只计有价行,无价 token 单列;缓存是输入 token 的子集,不另计">成本 {fmtCostUsd(ctx.costSummary.usd_micros)} | token {(ctx.costSummary.total_tokens ?? 0).toLocaleString()}(实际 {(ctx.costSummary.actual_tokens ?? 0).toLocaleString()} / 估算 {(ctx.costSummary.estimated_tokens ?? 0).toLocaleString()},缓存 {(ctx.costSummary.cached_input_tokens ?? 0).toLocaleString()},无价 {(ctx.costSummary.unpriced_actual_tokens ?? 0).toLocaleString()})| 轮数 {ctx.costSummary.turns ?? 0} | fallback {ctx.costSummary.fallback_count ?? 0}</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 || "默认"}