feat(web): 智能体会话去噪——单一免责、短标题、技术信息折叠(DEAI-2)

- 「未经核查」只在最终汇报左栏标题保留一处,删「仅供参考」与数据来源说明;
  右栏改叫「实际结果」,去掉「运行已完成」「工具成功 N」徽章,计数只列非零项,
  状态值用中文标签,不再显示计划 / 记录短 ID
- 工具卡片只标会改数据的动作和中高风险;能力 ID、scope、幂等、耗时、调用 ID、
  schema 入口收进技术信息;拒绝说明改成一句「要你批准」
- 用量只显示费用与时长,工具调用数、token、模型、会话 ID 进技术信息(超限时照常显示)
- 会话标题 shortGoalTitle:去上下文前缀、UUID、JSON 参数,能力 ID 换中文名,取第一句,40 字截断
- 入口去口号:删「交代目标,边做边汇报」与抽屉里的重复快捷键说明
- 对话组件结构不动

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 20:52:48 +08:00
1 parent 260f3ab27f
commit 5a717e414c
11 files changed
+223 -143

No files matched your search

@@ -32,6 +32,8 @@ import {
useDecideApproval,
useIsAdminSession,
} from "~/api/agent.ts";
import { DOWNLOAD_STATUS_LABEL, PLAN_STATUS_LABEL } from "~/api/downloads.ts";
import { RECORD_STATUS_LABEL } from "~/api/records.ts";
import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx";
import { type CitationItem, CitationList } from "~/components/agentui/citations.tsx";
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
@@ -51,8 +53,11 @@ import { StreamingCursor, StreamingResponse } from "~/components/agentui/streami
import { ToolApproval } from "~/components/agentui/tool-approval.tsx";
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { humanizeReason } from "~/lib/reasons.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
import { ToolCallIo } from "./tool-call-card-io.tsx";
@@ -103,16 +108,13 @@ function CapabilityBadges({ id, extra }: { id: string; extra?: ReactNode }) {
const m = capabilityMeta(id);
return (
<>
{/* 只标需要注意的:会改动数据的动作和中高风险;只读、scope、幂等进技术信息 */}
{m ? (
<>
<StatusBadge>{KIND_LABEL[m.kind] ?? m.kind}</StatusBadge>
<StatusBadge>scope · {m.scope}</StatusBadge>
<RiskBadge level={m.riskLevel} />
{m.idempotent ? <StatusBadge>幂等</StatusBadge> : null}
{m.kind !== "read" ? <StatusBadge>{KIND_LABEL[m.kind] ?? m.kind}</StatusBadge> : null}
{m.riskLevel !== "low" ? <RiskBadge level={m.riskLevel} /> : null}
</>
) : (
<StatusBadge>未登记能力</StatusBadge>
)}
) : null}
{extra}
</>
);
@@ -197,20 +199,21 @@ export function ToolCallCard({
<ToolApproval
status="rejected"
tool={call.capabilityId}
title={`服务端拒绝了「${meta?.title ?? call.capabilityId}」`}
title={`未执行:${meta?.title ?? "这个操作"}`}
badges={
<CapabilityBadges
id={call.capabilityId}
extra={<StatusBadge tone="destructive">{authReason ? "授权校验未通过" : "白名单拦截"}</StatusBadge>}
extra={<StatusBadge tone="destructive">{authReason ? "需要你批准" : "本次没有开放"}</StatusBadge>}
/>
}
reason={authReason ?? call.reason ?? "未授权"}
explanation={
authReason
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。控制台发起的会话里,需审批的动作会在对应步骤出审批卡;也可以发布收录策略生成持续授权。'
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
? "会改动数据的操作要你批准:在对应步骤的审批卡上批准,或在设置 → 智能体里给它持续授权。"
: "这次会话没有开放这个操作,或已用完调用次数。"
}
parameters={[
{ id: "cap", label: "能力", value: call.capabilityId },
{ id: "call", label: "调用 ID", value: call.toolCallId },
...(audit?.idempotencyKey ? [{ id: "idem", label: "幂等键", value: audit.idempotencyKey }] : []),
{ id: "at", label: "时间", value: formatDateTime(call.startedAt) },
@@ -222,9 +225,8 @@ export function ToolCallCard({
return (
<ToolResult
status={status}
tool={call.capabilityId}
tool={meta ? null : call.capabilityId}
title={meta?.title ?? call.capabilityId}
meta={durationText(call.startedAt, call.endedAt) ?? undefined}
badges={
<CapabilityBadges
id={call.capabilityId}
@@ -244,23 +246,39 @@ export function ToolCallCard({
) : call.status === "running" ? (
<p className="text-meta text-muted-foreground">等待结果…</p>
) : null}
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
<dt className="text-muted-foreground">调用 ID</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">{call.toolCallId}</dd>
<dt className="text-muted-foreground">幂等键</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">
{audit?.idempotencyKey ?? (meta && meta.kind !== "read" ? "服务端已生成,审计里没有取到" : "只读操作不记录")}
</dd>
{audit?.grantId || audit?.approvalId ? (
<>
<dt className="text-muted-foreground">授权依据</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">
{audit.grantId ? `持续授权 ${audit.grantId.slice(0, 8)}` : `单次审批 ${audit.approvalId?.slice(0, 8)}`}
</dd>
</>
) : null}
</dl>
<ToolCallIo call={call} />
<TechInfo>
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
<dt className="text-muted-foreground">能力</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">
{call.capabilityId}
{meta
? ` · ${KIND_LABEL[meta.kind] ?? meta.kind} · scope ${meta.scope}${meta.idempotent ? " · 幂等" : ""}`
: ""}
</dd>
{durationText(call.startedAt, call.endedAt) ? (
<>
<dt className="text-muted-foreground">耗时</dt>
<dd className="min-w-0 text-foreground/80">{durationText(call.startedAt, call.endedAt)}</dd>
</>
) : null}
<dt className="text-muted-foreground">调用 ID</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">{call.toolCallId}</dd>
<dt className="text-muted-foreground">幂等键</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">
{audit?.idempotencyKey ??
(meta && meta.kind !== "read" ? "服务端已生成,审计里没有取到" : "只读操作不记录")}
</dd>
{audit?.grantId || audit?.approvalId ? (
<>
<dt className="text-muted-foreground">授权依据</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">
{audit.grantId ? `持续授权 ${audit.grantId.slice(0, 8)}` : `单次审批 ${audit.approvalId?.slice(0, 8)}`}
</dd>
</>
) : null}
</dl>
<ToolCallIo call={call} />
</TechInfo>
</ToolResult>
);
}
@@ -772,35 +790,60 @@ export function BudgetBar({
? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime()
: undefined);
return (
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2 sm:grid-cols-4")}>
<Meter
label="工具调用"
used={toolCalls}
max={run.budget.maxToolCalls}
format={(n) => String(n)}
exceeded={exceeded === "tool_calls"}
/>
<Meter
label="token"
used={u.totalTokens}
max={run.budget.maxTokens}
format={(n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))}
exceeded={exceeded === "tokens"}
/>
<Meter
label="时长"
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
max={run.budget.maxDurationSec}
format={(n) => `${n}s`}
exceeded={exceeded === "duration"}
/>
<Meter
label="费用(估算)"
used={u.costUsd}
max={run.budget.maxCostUsd}
format={(n) => `$${n.toFixed(3)}`}
exceeded={exceeded === "cost"}
/>
<div className="grid gap-2">
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2")}>
<Meter
label="费用(估算)"
used={u.costUsd}
max={run.budget.maxCostUsd}
format={(n) => formatUsd(n)}
exceeded={exceeded === "cost"}
/>
<Meter
label="时长"
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
max={run.budget.maxDurationSec}
format={(n) => `${n} 秒`}
exceeded={exceeded === "duration"}
/>
</div>
{/* 调用次数与 token 超限时直接显示,否则收进技术信息 */}
{exceeded === "tool_calls" || exceeded === "tokens" ? (
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2")}>
<Meter
label="工具调用"
used={toolCalls}
max={run.budget.maxToolCalls}
format={(n) => String(n)}
exceeded={exceeded === "tool_calls"}
/>
<Meter
label="token"
used={u.totalTokens}
max={run.budget.maxTokens}
format={(n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))}
exceeded={exceeded === "tokens"}
/>
</div>
) : (
<TechInfo
items={[
{
label: "工具调用",
value: `${toolCalls}${run.budget.maxToolCalls ? ` / ${run.budget.maxToolCalls}` : ""}`,
},
{
label: "token",
value:
u.totalTokens === undefined
? null
: `${u.totalTokens.toLocaleString("zh-CN")}${run.budget.maxTokens ? ` / ${run.budget.maxTokens.toLocaleString("zh-CN")}` : ""}`,
},
{ label: "模型", value: run.model ? `${run.provider ? `${run.provider}/` : ""}${run.model}` : null },
{ label: "会话 ID", value: run.id, wide: true },
]}
/>
)}
</div>
);
}
@@ -946,17 +989,25 @@ export function FinalReport({
...report.downloads.map((d) => ({
id: `d${d.downloadTaskId}`,
icon: <Download />,
title: d.name ?? `下载任务 ${d.downloadTaskId.slice(0, 8)}`,
title: d.name ?? "下载任务",
domain: "下载任务",
meta: <StatusBadge tone={PLAN_STATUS_TONE(d.status)}>{d.status}</StatusBadge>,
meta: (
<StatusBadge tone={PLAN_STATUS_TONE(d.status)}>
{DOWNLOAD_STATUS_LABEL[d.status as keyof typeof DOWNLOAD_STATUS_LABEL] ?? d.status}
</StatusBadge>
),
to: `/console/downloads/${d.downloadTaskId}`,
})),
...report.organization.map((o) => ({
id: `o${o.recordId}`,
icon: <FolderInput />,
title: o.targetPath ?? `整理记录 ${o.recordId.slice(0, 8)}`,
title: o.targetPath ?? "整理记录",
domain: "整理记录",
meta: <StatusBadge tone={PLAN_STATUS_TONE(o.status)}>{o.status}</StatusBadge>,
meta: (
<StatusBadge tone={PLAN_STATUS_TONE(o.status)}>
{RECORD_STATUS_LABEL[o.status as keyof typeof RECORD_STATUS_LABEL] ?? o.status}
</StatusBadge>
),
to: `/console/organize/${o.recordId}`,
})),
]
@@ -975,7 +1026,6 @@ export function FinalReport({
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
</StreamingResponse>
</LongText>
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
</div>
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
@@ -984,19 +1034,19 @@ export function FinalReport({
) : (
<LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />
)}
系统核实的结果
实际结果
</p>
<div className="flex flex-wrap gap-1.5">
{state ? <StatusBadge tone={state.tone}>{state.label}</StatusBadge> : null}
<StatusBadge tone={RUN_STATUS_TONE[run.status]}>运行{RUN_STATUS_LABEL[run.status]}</StatusBadge>
<StatusBadge>工具成功 {count("succeeded")}</StatusBadge>
{count("failed") ? <StatusBadge tone="destructive">失败 {count("failed")}</StatusBadge> : null}
{denied ? <StatusBadge tone="destructive">被拒绝 {denied}</StatusBadge> : null}
</div>
{state || count("failed") || denied ? (
<div className="flex flex-wrap gap-1.5">
{state ? <StatusBadge tone={state.tone}>{state.label}</StatusBadge> : null}
{count("failed") ? <StatusBadge tone="destructive">{count("failed")} 步失败</StatusBadge> : null}
{denied ? <StatusBadge tone="destructive">{denied} 步未执行</StatusBadge> : null}
</div>
) : null}
{report ? (
<>
<p className="mt-2 text-caption break-words text-foreground">{report.summary}</p>
<dl className="mt-2 grid grid-cols-4 gap-2 text-center">
<dl className="mt-2 flex flex-wrap gap-2 text-center">
{(
[
["已提交", report.submitted],
@@ -1004,20 +1054,26 @@ export function FinalReport({
["进行中", report.inProgress],
["失败", report.failed],
] as const
).map(([k, v]) => (
<div key={k} className="flex flex-col-reverse rounded-lg border border-border/60 px-1 py-1.5">
<dt className="text-meta text-muted-foreground">{k}</dt>
<dd className="text-lead font-semibold text-foreground tabular-nums">{v}</dd>
</div>
))}
)
.filter(([, v]) => v > 0)
.map(([k, v]) => (
<div
key={k}
className="flex min-w-16 flex-col-reverse rounded-lg border border-border/60 px-2 py-1.5"
>
<dt className="text-meta text-muted-foreground">{k}</dt>
<dd className="text-lead font-semibold text-foreground tabular-nums">{v}</dd>
</div>
))}
</dl>
{report.plans.length ? (
<ul className="mt-2 space-y-1 text-meta">
{report.plans.map((p) => (
<li key={p.planId} className="flex min-w-0 items-center gap-2">
<span className="text-muted-foreground">下载计划</span>
<code className="min-w-0 truncate font-mono text-foreground/80">{p.planId.slice(0, 8)}</code>
<StatusBadge tone={PLAN_STATUS_TONE(p.status)}>{p.status}</StatusBadge>
<StatusBadge tone={PLAN_STATUS_TONE(p.status)}>
{PLAN_STATUS_LABEL[p.status as keyof typeof PLAN_STATUS_LABEL] ?? p.status}
</StatusBadge>
</li>
))}
</ul>
@@ -1036,18 +1092,17 @@ export function FinalReport({
}))}
/>
) : null}
<p className="mt-2 text-meta text-muted-foreground">
取自下载计划、下载任务和整理记录(生成于 {formatDateTime(report.generatedAt)}
)。「已提交」指已进入下载器,整理成功才算入库。
</p>
</>
) : (
<p className="mt-2 text-meta text-muted-foreground">服务端汇报还没有生成,以任务和整理记录的实际状态为准。</p>
)}
{run.error ? (
<p className="mt-2 text-caption break-words text-destructive">
{String((run.error as { message?: unknown }).message ?? JSON.stringify(run.error))}
</p>
<>
<p className="mt-2 text-caption break-words text-destructive">
{humanizeReason(String((run.error as { message?: unknown }).message ?? "")) || "会话出错"}
</p>
<TechInfo className="mt-1" items={[{ label: "原始错误", value: JSON.stringify(run.error), wide: true }]} />
</>
) : null}
</div>
</section>
@@ -25,8 +25,8 @@ import {
setAgentDrawerOpen,
setAgentDrawerRun,
setAgentDrawerTab,
shortGoalTitle,
showAgentRunInDrawer,
splitContextGoal,
useAgentDrawer,
} from "./agent-store.ts";
import { AgentRunOpenContext } from "./agent-subagent-tree.tsx";
@@ -108,7 +108,7 @@ function HistoryRunView({ runId }: { runId: string }) {
返回列表
</Button>
<p className="min-w-0 flex-1 truncate text-caption text-muted-foreground" title={run.data?.run.goal}>
{run.data ? splitContextGoal(run.data.run.goal).text : ""}
{run.data ? shortGoalTitle(run.data.run.goal) : ""}
</p>
<Button asChild variant="ghost" size="xs" className="shrink-0 text-muted-foreground">
<Link to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
@@ -163,11 +163,8 @@ export function AgentDrawer() {
<Bot className="h-4 w-4" />
智能体
</SheetTitle>
<SheetDescription className={cn("break-words", history && "sr-only")}>
{context?.label ? `当前对象:${context.label}` : "交代目标,智能体在授权范围内执行并汇报。"}
<span className="ml-1 hidden text-muted-foreground/75 sm:inline">
({agentShortcutLabel()} 开关,Esc 关闭)
</span>
<SheetDescription className={cn("break-words", (history || !context?.label) && "sr-only")}>
{context?.label ? `当前对象:${context.label}` : `${agentShortcutLabel()} 打开或收起`}
</SheetDescription>
{context && !history ? <ContextChips context={context} /> : null}
<DrawerTabs tab={tab} />
@@ -288,7 +285,7 @@ function AgentMiniDock({ runId }: { runId: string }) {
<Bot aria-hidden className="size-4 shrink-0 text-muted-foreground" />
{status ? <RunStatusBadge status={status} /> : null}
<span className="min-w-0 flex-1 truncate text-meta text-muted-foreground">
{run.data ? splitContextGoal(run.data.run.goal).text : ""}
{run.data ? shortGoalTitle(run.data.run.goal) : ""}
</span>
<Button variant="ghost" size="icon-xs" aria-label="展开智能体" title="展开" onClick={restoreAgentDrawer}>
<Maximize2 />
@@ -326,7 +323,7 @@ export function AgentDrawerTrigger({ className }: { className?: string }) {
variant="ghost"
size="icon-sm"
aria-label="打开智能体"
title={`Agent(${agentShortcutLabel()})`}
title={`智能体(${agentShortcutLabel()})`}
className={className}
onClick={() =>
minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab())
@@ -11,7 +11,7 @@ import {
toolSummary,
} from "./agent-conversation.tsx";
import { matchesHistory, rangeStart } from "./agent-session-list.tsx";
import { contextFromPath, contextPrefix, splitContextGoal } from "./agent-store.ts";
import { contextFromPath, contextPrefix, shortGoalTitle, splitContextGoal } from "./agent-store.ts";
import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts";
const call = (p: Partial<ToolCallView>): ToolCallView => ({
@@ -185,3 +185,20 @@ describe("会话历史筛选", () => {
expect(rangeStart("", now)).toBeNull();
});
});
describe("shortGoalTitle(会话短标题)", () => {
it("去 UUID、JSON 参数,能力 ID 换中文名,取第一句", () => {
const t = shortGoalTitle(
'在浏览器会话 16be7338-1111-4222-8333-944445555666 上直接调用一次 browser.solve_challenge,参数:{"sessionId":"x","mode":"auto"}',
);
expect(t).not.toMatch(/16be7338|\{|sessionId/);
expect(t.startsWith("在浏览器会话上直接调用一次")).toBe(true);
});
it("去上下文前缀,超长截断", () => {
expect(shortGoalTitle("[上下文 作品:沙丘] 把沙丘:第二部的 4K 收进来。然后再看看别的")).toBe(
"把沙丘:第二部的 4K 收进来",
);
expect(shortGoalTitle("啊".repeat(60))).toBe(`${"啊".repeat(40)}…`);
expect(shortGoalTitle(" ")).toBe("未命名会话");
});
});
@@ -25,6 +25,7 @@ import { PromptInput } from "~/components/agentui/prompt-input.tsx";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { Label } from "~/components/ui/label.tsx";
import { Switch } from "~/components/ui/switch.tsx";
@@ -115,8 +116,7 @@ export function GoalComposer({
<p className="px-1 text-meta text-muted-foreground" aria-live="polite">
{proposeFirst
? "智能体先调查并给出方案,你选定后才执行。"
: "智能体列出步骤后直接执行,需要审批的动作会停在对应步骤等你批准。"}{" "}
Enter 发送,Shift+Enter 换行。
: "智能体列出步骤后直接执行,需要审批的动作会停在对应步骤等你批准。"}
</p>
{suggestions?.length ? (
<div className="pt-2">
@@ -143,10 +143,6 @@ export function GoalComposer({
);
}
function shortTokens(n: number | undefined) {
return n === undefined ? null : n >= 1000 ? `${(n / 1000).toFixed(1)}k token` : `${n} token`;
}
/** 状态 + 来源 + 时间 + 模型 */
function RunMeta({ run, status }: { run: RunSnapshot["run"]; status: AgentRunStatus }) {
return (
@@ -163,7 +159,6 @@ function RunMeta({ run, status }: { run: RunSnapshot["run"]; status: AgentRunSta
</div>
<p className="text-meta break-words text-muted-foreground">
{TRIGGER_LABEL[run.trigger]} · {formatDateTime(run.createdAt)}
{run.model ? ` · ${run.provider ?? ""}/${run.model}` : ""}
</p>
</div>
);
@@ -194,8 +189,7 @@ function RunOverviewBar({
const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending");
const failed = steps.some((s) => s.status === "failed");
const u = { ...r.usage, ...(live.usage ?? {}) };
const calls = Math.max(r.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
const usage = [`${calls} 次调用`, shortTokens(u.totalTokens)].filter(Boolean).join(" · ");
const usage = u.costUsd ? `费用约 ${formatUsd(u.costUsd)}` : "";
return (
<section aria-label="会话概况" className={cn("shrink-0 rounded-xl border border-border/70", className)}>
<button
@@ -240,7 +234,7 @@ function RunOverviewBar({
<RunPlan plan={live.plan} active={active} fill />
<SubagentTree snapshot={snapshot} className="p-2.5" />
<div>
<p className="mb-2 text-meta text-muted-foreground">预算与用量</p>
<p className="mb-2 text-meta text-muted-foreground">用量</p>
<BudgetBar run={r} live={live} />
</div>
</div>
@@ -462,8 +456,8 @@ export function AgentRunPanel({
<RunMeta run={r} status={shownStatus} />
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} fill />
<SubagentTree snapshot={run.data} className="p-2.5" />
<section aria-label="预算与用量" className="mt-auto rounded-xl border border-border/70 p-3">
<p className="mb-2 text-meta text-muted-foreground">预算与用量</p>
<section aria-label="用量" className="mt-auto rounded-xl border border-border/70 p-3">
<p className="mb-2 text-meta text-muted-foreground">用量</p>
<BudgetBar run={r} live={live} stacked />
</section>
</aside>
@@ -4,13 +4,14 @@ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "re
import { Link } from "react-router";
import { type AgentRunView, RUN_STATUS_LABEL, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { cn } from "~/lib/utils.ts";
import { RunStatusBadge } from "./agent-conversation.tsx";
import { splitContextGoal } from "./agent-store.ts";
import { shortGoalTitle, splitContextGoal } from "./agent-store.ts";
// 会话历史列表:会话页左栏与抽屉「历史」Tab 共用。状态、来源在服务端筛选(游标翻页),
// 搜索与时间在已加载的条目里筛(按创建时间倒序,早于时间范围的页不再加载)。
@@ -57,16 +58,9 @@ export function matchesHistory(
return run.goal.toLowerCase().includes(q) || run.id.startsWith(q);
}
/** 列表副行:只写费用(token 与调用次数在会话的技术信息里) */
export function usageText(r: Pick<AgentRunView, "toolCallCount" | "usage">) {
const parts = [`${r.toolCallCount} 次调用`];
if (r.usage.totalTokens !== undefined)
parts.push(
r.usage.totalTokens >= 1000
? `${(r.usage.totalTokens / 1000).toFixed(1)}k token`
: `${r.usage.totalTokens} token`,
);
if (r.usage.costUsd !== undefined) parts.push(`$${r.usage.costUsd.toFixed(3)}`);
return parts.join(" · ");
return r.usage.costUsd ? `费用约 ${formatUsd(r.usage.costUsd)}` : "";
}
const ALL = "__all__";
@@ -115,7 +109,7 @@ function RunRow({ r, current, children }: { r: AgentRunView; current: boolean; c
<>
<div className="flex min-w-0 items-center gap-2">
<p className="min-w-0 flex-1 truncate text-caption text-foreground" title={goal.text}>
{goal.text}
{shortGoalTitle(r.goal)}
</p>
{children}
</div>
@@ -130,10 +124,11 @@ function RunRow({ r, current, children }: { r: AgentRunView; current: boolean; c
{relTime(r.createdAt)}
</time>
</div>
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">
{goal.context ? `${goal.context.split(" · ")[0]} · ` : ""}
{usageText(r)}
</p>
{goal.context || usageText(r) ? (
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">
{[goal.context?.split(" · ")[0], usageText(r)].filter(Boolean).join(" · ")}
</p>
) : null}
</>
);
}
@@ -1,3 +1,4 @@
import { CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
import { useSyncExternalStore } from "react";
// Agent 侧栏抽屉的全局开关与上下文:任意页面调用 openAgentDrawer({ entityId, ... }) 即可带着当前对象打开。
@@ -192,3 +193,29 @@ export function splitContextGoal(goal: string): { context: string | null; text:
const m = /^\[上下文 ([^\]]*)\]\s*/.exec(goal);
return m ? { context: m[1] ?? null, text: goal.slice(m[0].length) } : { context: null, text: goal };
}
const capabilityTitle = (id: string): string | null =>
(CAPABILITY_CATALOG as Record<string, { title?: string }>)[id as KnownCapabilityId]?.title ?? null;
const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
const CAP_RE = /\b[a-z][a-z_]*(?:\.[a-z_]+)+\b/g;
/**
* 会话标题:从目标生成短标题(DEAI-2)。去掉上下文前缀、UUID、JSON 参数,能力 ID 换成中文名,
* 取第一句,超过 40 字截断。完整目标仍在会话里第一条消息。
*/
export function shortGoalTitle(goal: string, max = 40): string {
let t = splitContextGoal(goal).text;
t = t.replace(/\s*[{[]\s*["{[][\s\S]*$/, ""); // JSON 参数到结尾
t = t.replace(UUID_RE, "");
t = t.replace(/\b[0-9a-f]{8}(?:-[0-9a-f]{0,4})*…?/gi, ""); // 截断的短 ID
t = t.replace(CAP_RE, (id) => capabilityTitle(id) ?? id);
t = t.split(/[\n。!?!?]/)[0] ?? "";
t = t
.replace(/\s+/g, " ")
.replace(/(?<=[\p{Script=Han}(])\s+(?=[\p{Script=Han},。:;、)])/gu, "")
.replace(/[,,::;;、\s]*(?:参数|args?)?[::]?\s*$/i, "")
.trim();
if (!t) return "未命名会话";
return t.length > max ? `${t.slice(0, max)}…` : t;
}
@@ -5,7 +5,7 @@ import { Link } from "react-router";
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
import { cn } from "~/lib/utils.ts";
import { RunStatusBadge } from "./agent-conversation.tsx";
import { splitContextGoal } from "./agent-store.ts";
import { shortGoalTitle } from "./agent-store.ts";
// 子代理(agent.spawn):会话页显示父会话链接 + 继承信息,以及子会话树(可展开、可跳转)与会话树合计用量。
// 未派生过子会话、也不是子会话时不渲染(不显示空的多 Agent 看板)。
@@ -70,7 +70,7 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<str
<span className="mt-0.5 size-4 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1">
<RunLink id={node.id}>{splitContextGoal(node.goal).text}</RunLink>
<RunLink id={node.id}>{shortGoalTitle(node.goal)}</RunLink>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
<RunStatusBadge status={node.status} />
<span className="text-meta text-muted-foreground tabular-nums">
@@ -116,7 +116,7 @@ export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; c
<CornerLeftUp aria-hidden className="size-3.5" />
父会话
</p>
<RunLink id={parent.id}>{splitContextGoal(parent.goal).text}</RunLink>
<RunLink id={parent.id}>{shortGoalTitle(parent.goal)}</RunLink>
<p className="text-meta text-muted-foreground">
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
{budgetText(run.budget as Record<string, number | undefined>)}