feat(experimental): rebalance perf columns and attribute wake senders
This commit is contained in:
1 parent
dc63639968
commit
878b93f58a
16 files changed
+274
-148
No files matched your search
@@ -1,22 +1,22 @@
|
||||
- table "Performance":
|
||||
- rowgroup:
|
||||
- row "Turn / step Question Did Context Output Cache hit First token Model time Output speed Tool time Total time":
|
||||
- row "Turn / step Trigger Execution Context Output Cache hit First token Model time Output speed (tok/s) Tool time Total time":
|
||||
- columnheader "Turn / step"
|
||||
- columnheader "Question"
|
||||
- columnheader "Did"
|
||||
- columnheader "Trigger"
|
||||
- columnheader "Execution"
|
||||
- columnheader "Context"
|
||||
- columnheader "Output"
|
||||
- columnheader "Cache hit"
|
||||
- columnheader "First token"
|
||||
- columnheader "Model time"
|
||||
- columnheader "Output speed"
|
||||
- columnheader "Output speed (tok/s)"
|
||||
- columnheader "Tool time"
|
||||
- columnheader "Total time"
|
||||
- rowgroup:
|
||||
- row "Expand or collapse turn 1 · 1 steps · Hide · {{clock}} Show the measured round. read ×1 → 7 chars 5.2K 0.24K 69.23% {{ttft}} {{duration}} {{tps}} {{duration}} {{duration}}":
|
||||
- cell "Expand or collapse turn 1 · 1 steps · Hide · {{clock}}":
|
||||
- row "Expand or collapse turn 1 · 1 steps · 10-22 {{clock}} Show the measured round. read ×1 → 7 chars 5.2K 0.24K 69.23% {{ttft}} {{duration}} {{tps}} {{duration}} {{duration}}":
|
||||
- cell "Expand or collapse turn 1 · 1 steps · 10-22 {{clock}}":
|
||||
- button "Expand or collapse turn 1" [expanded]: Turn 1
|
||||
- text: · 1 steps · Hide · {{clock}}
|
||||
- text: · 1 steps · 10-22 {{clock}}
|
||||
- cell "Show the measured round."
|
||||
- cell "read ×1 → 7 chars"
|
||||
- cell "5.2K"
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 092989f386525568
|
||||
zh: 4a6276cfc268c851
|
||||
/deepseek-ai-dsh-experimental-client-ui-perf/summary:
|
||||
en: a8ca7bbd9e14a3df
|
||||
zh: 6450dd5c4de03f41
|
||||
en: 284607c5791a44d9
|
||||
zh: a231eac33a8186a1
|
||||
/deepseek-ai-dsh-experimental-client-ui-perf/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
@@ -24,8 +24,8 @@
|
||||
en: e001601f2f9779b1
|
||||
zh: 077d3703b8c82774
|
||||
/deepseek-ai-dsh-experimental-client-ui-perf/understand-the-implementation:
|
||||
en: 419a3c1b9360721b
|
||||
zh: 20af2a2534ac3652
|
||||
en: 289e8a539820fa4d
|
||||
zh: 5dad2cfac77761e8
|
||||
/deepseek-ai-dsh-experimental-client-ui-perf/further-exploration:
|
||||
en: 285cfe1d7a99af34
|
||||
zh: fbcee76e0c2f20fb
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 7605bb868f93b4a8
|
||||
zh: 184ca10ce6680499
|
||||
/deepseek-ai-dsh-experimental-client-ui-perf/known-limitations-and-deferred-work:
|
||||
en: f9e1b7b047937d51
|
||||
zh: 804a52a6815823e8
|
||||
en: f4a31f4b3e5afab6
|
||||
zh: 12893e87a5dee89b
|
||||
/deepseek-ai-dsh-experimental-client-ui-perf/known-limitations-and-deferred-work/dev-note:
|
||||
en: 3518dbaf30fb19a6
|
||||
zh: c3c929308e955ebb
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Adds a Performance tab beside Chat and Trajectory. Each turn is one row; expanding it lists its steps with context tokens, output tokens, cache hit rate, first-token latency, model time, output speed, tool time, and a total-time bar split into first token, generation, tools, and the rest. A running step shows elapsed time instead of a blank. Scrolling to the top pulls earlier turns through the same Session paging the other views use; there are no page controls. When a step has no tool calls, its answer label is followed by the first line of the model reply, with the full text on hover. The Trigger column classifies what opened the turn: a human prompt shows its text, while a team message, subagent settlement, goal round, or runtime/time context shows a localized trigger badge plus the triggering text. A prompt that attached images shows an image-count pill in the same style as the trigger badge. Hovering or keyboard-focusing the pill opens a small preview strip, and clicking a preview loads that image in the shared image viewer; an image-only prompt shows the pill instead of an empty cell. The panel reads Session events and the prompt's durable images; it registers no tool, prompt section, or service.
|
||||
Adds a Performance tab beside Chat and Trajectory. Each turn is one row; expanding it lists its steps with context tokens, output tokens, cache hit rate, first-token latency, model time, output speed, tool time, and a total-time bar split into first token, generation, tools, and the rest. A running step shows elapsed time instead of a blank. Scrolling to the top pulls earlier turns through the same Session paging the other views use, with a `Load earlier` fallback button, an explicit retry state for a page that failed or did not advance, and an end-of-history marker. When a step has no tool calls, its answer label is followed by the first line of the model reply, with the full text on hover. The Trigger column classifies what opened the turn: a human prompt shows its text, while a team/agent message, subagent settlement, goal round, scheduled/webhook/job run, or runtime/time context shows a localized trigger badge plus the triggering text; a team or agent message also names its sender in the badge. A prompt that attached images shows an image-count pill in the same style as the trigger badge. Hovering or keyboard-focusing the pill opens a small preview strip, and clicking a preview loads that image in the shared image viewer; an image-only prompt shows the pill instead of an empty cell. The panel reads Session events and the prompt's durable images; it registers no tool, prompt section, or service.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -46,7 +46,7 @@ Choose it when you need to compare turns or steps by prompt length, cache reuse,
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
Four Conversation Definitions fold the event window into nodes for one view target named `perf`. `perf-step` owns one context per `step/start`; `perf-prompt` owns one context per human-typed user message; `perf-turn-end` records the turn boundary so an interrupted tail closes; `perf-code-mode` owns one context per Code-Mode root call for the inner dispatches its runner performed. Turn and step identity comes from the engine's match Location, never from module state, so history paging and multiple Sessions cannot cross-attribute events.
|
||||
Four Conversation Definitions fold the event window into nodes for one view target named `perf`. `perf-step` owns one context per `step/start`; `perf-prompt` owns one context per waking user message (human prompt or automated trigger); `perf-turn-end` records the turn boundary so an interrupted tail closes; `perf-code-mode` owns one context per Code-Mode root call for the inner dispatches its runner performed. Turn and step identity comes from the engine's match Location, never from module state, so history paging and multiple Sessions cannot cross-attribute events.
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
@@ -90,7 +90,7 @@ No effect. The package never constructs a model request and never mutates a prom
|
||||
- **Code-Mode dispatches merge by root-call context.** Inner calls carry no turn or step, so each root call opens its own context keyed by the root call id, and the builder merges its dispatches into the step that owns the runner. A window that lost the dispatch start still merges the calls but cannot show their measured duration.
|
||||
- **Browser evidence is a test-only overlay.** Package tests cover folding, aggregation, formatting, and the jsdom table; `apps/web/tests/client-ui-perf-panel.e2e.ts` mounts the plugin through a test-only overlay over the shipped Web surface and compares a committed ARIA golden. No recorded-session scenario exists because no shipped profile mounts the package yet.
|
||||
- **Late re-deliveries are not turns.** Compaction can restate a pruned tool result under its original turn and step after that turn left the window; a turn with no loaded start, prompt, or end marker is dropped instead of appearing as an empty ghost row.
|
||||
- **Timing is derived, not sampled.** Total time is the last step's end minus the first step's start; queueing and scheduler waits land in the "rest" segment. The total-time bar draws tool calls as the union of their paired intervals, so parallel calls count once; the tool column keeps the summed per-call time.
|
||||
- **Timing is derived, not sampled.** Total time is the last step's end minus the first step's start; queueing and scheduler waits land in the "rest" segment. The total-time bar draws tool calls as the union of their paired intervals, so parallel calls count once; the tool column keeps the summed per-call time. A turn bar is scaled against the turns currently visible on screen, so a much larger turn that scrolled away does not flatten the rows in view.
|
||||
- **Prompt images load through the shared image cache.** The pill and its preview resolve durable attachments with `uiConversation.imageUrl`, so the cache owns and revokes the browser URLs per Session binding; the view creates none of its own. A failed read shows a localized failure state with retry. Touch clients do not open the hover preview; tapping the pill loads the first image directly.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
在「对话」和「轨迹」旁边加一个「性能」页签。每一轮一行,展开后列出各步的上下文词数、输出词数、缓存命中率、首 token 延迟、模型用时、输出速度、工具调用用时,以及一条按首 token、生成、工具调用、其余四段拆开的总用时条。还在跑的步显示已用时间,而不是留空。「触发」列会说明这一轮由什么触发:人写的提问显示原文,团队消息、子代理完成、目标轮次或上下文唤醒显示本地化触发标签加触发文本。带图的提问在「触发」列显示与触发标签同样式的张数标签;悬停或键盘聚焦标签会浮出小图预览,点击预览用共用看图器加载该图,只有图片的提问显示标签而不是空单元格。页签只读会话事件和提问里的持久图片,不注册工具、提示词段落或服务。
|
||||
在「对话」和「轨迹」旁边加一个「性能」页签。每一轮一行,展开后列出各步的上下文词数、输出词数、缓存命中率、首 token 延迟、模型用时、输出速度、工具调用用时,以及一条按首 token、生成、工具调用、其余四段拆开的总用时条。还在跑的步显示已用时间,而不是留空。「触发」列会说明这一轮由什么触发:人写的提问显示原文,团队/智能体消息、子代理完成、目标轮次、定时/Webhook/后台任务或上下文唤醒显示本地化触发标签加触发文本;团队或智能体消息还会在标签里写明发送方。带图的提问在「触发」列显示与触发标签同样式的张数标签;悬停或键盘聚焦标签会浮出小图预览,点击预览用共用看图器加载该图,只有图片的提问显示标签而不是空单元格。页签只读会话事件和提问里的持久图片,不注册工具、提示词段落或服务。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -46,7 +46,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
四个 Conversation Definition 把事件窗口折叠成名为 `perf` 的视图目标节点。`perf-step` 每个 `step/start` 一个上下文;`perf-prompt` 每条人写的用户消息一个上下文;`perf-turn-end` 记录轮次边界,让被中断的尾部也能收尾;`perf-code-mode` 每个 Code-Mode 根调用一个上下文,收纳它实际派发的内层调用。轮次与步骤身份取自引擎给出的 match Location,而不是模块级状态,因此历史分页和多个 Session 都不会串归属。
|
||||
四个 Conversation Definition 把事件窗口折叠成名为 `perf` 的视图目标节点。`perf-step` 每个 `step/start` 一个上下文;`perf-prompt` 每条唤醒该轮的用户消息(人写提示或自动触发)一个上下文;`perf-turn-end` 记录轮次边界,让被中断的尾部也能收尾;`perf-code-mode` 每个 Code-Mode 根调用一个上下文,收纳它实际派发的内层调用。轮次与步骤身份取自引擎给出的 match Location,而不是模块级状态,因此历史分页和多个 Session 都不会串归属。
|
||||
|
||||
| 文件 | 职责 |
|
||||
| --- | --- |
|
||||
@@ -90,7 +90,7 @@ kind: "package-reference"
|
||||
- **Code-Mode 内层调用按根调用上下文并回步骤。** 内层调用不带 turn/step,因此每个根调用各自开一个以 root call id 为键的上下文,由 builder 把它的派发并回持有 runner 的那一步。窗口里丢了派发起点时仍会合并调用,但无法给出它们各自的实测耗时。
|
||||
- **浏览器证据是仅测试用的 overlay。** 包级测试覆盖折叠、汇总、格式化与 jsdom 表格;`apps/web/tests/client-ui-perf-panel.e2e.ts` 用仅测试用的 overlay 把插件挂到真实 Web 面上,并比对提交的 ARIA golden。由于还没有任何 shipped profile 挂载本包,暂无录制会话场景。
|
||||
- **迟到重投不是轮次。** 压缩可能把被裁剪的工具结果按原来的轮次/步骤在之后重新投递;窗口里既没有加载到的起点、也没有问题或结束标记的轮次会被丢弃,不再显示成空的幽灵行。
|
||||
- **计时是推导值。** 总用时取最后一步结束减第一步开始;排队和调度等待落在「其余」段里。总用时条把工具调用画成配对区间之并,并行调用只算一次;工具列仍显示逐次求和。
|
||||
- **计时是推导值。** 总用时取最后一步结束减第一步开始;排队和调度等待落在「其余」段里。总用时条把工具调用画成配对区间之并,并行调用只算一次;工具列仍显示逐次求和。轮次时间条以当前屏幕内可见的轮次为刻度,滚出屏幕的超大轮次不会把本屏的行压扁。
|
||||
- **提问图片走共用图片缓存。** 标签与预览用 `uiConversation.imageUrl` 解析持久附件,浏览器 URL 由缓存按 Session binding 持有并释放,视图不自己创建 URL。读取失败时显示本地化失败状态并可重试。触屏不打开悬停预览,点击标签会直接加载第一张图。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
} from './perf-contract.ts'
|
||||
import {
|
||||
DASH, barShare, formatChars, formatClock, formatDateTime, formatDateTimeShort, formatMs, formatPercent,
|
||||
formatTokens, formatTokensDelta, formatTokensShort, formatTps,
|
||||
formatTokens, formatTokensShort, formatTps,
|
||||
} from './perf-format.ts'
|
||||
import { isNumber } from './perf-usage.ts'
|
||||
import { CALL_LIMIT } from './perf-fold.ts'
|
||||
@@ -176,15 +176,6 @@ interface TimeState {
|
||||
readonly toolWall: number | null
|
||||
}
|
||||
|
||||
/** Context growth from the previous step of the same turn. */
|
||||
interface ContextDelta {
|
||||
readonly from: number
|
||||
readonly to: number
|
||||
readonly diff: number
|
||||
readonly chars: number
|
||||
readonly tokens: number | null
|
||||
}
|
||||
|
||||
/** Text and tooltip of one numeric cell. */
|
||||
interface CellText {
|
||||
readonly text: string
|
||||
@@ -265,8 +256,6 @@ interface MetricCellsProps {
|
||||
/** Props of the context column cell. */
|
||||
interface ContextCellProps {
|
||||
readonly value: number | null
|
||||
readonly delta: number | null
|
||||
readonly title: string
|
||||
readonly className: string | undefined
|
||||
}
|
||||
|
||||
@@ -732,30 +721,6 @@ function callGroups(calls: readonly PerfCall[]): readonly CallGroup[] {
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
/** Context delta of each step against the previous measured step in its turn. */
|
||||
function contextDeltas(turn: PerfTurnMetrics): ReadonlyMap<number, ContextDelta> {
|
||||
const deltas = new Map<number, ContextDelta>()
|
||||
let previous: number | null = null
|
||||
let previousChars = 0
|
||||
let previousTokens: number | null = null
|
||||
for (const step of turn.steps) {
|
||||
const context = step.context
|
||||
if (isNumber(previous) && isNumber(context) && context !== previous) {
|
||||
deltas.set(step.step, {
|
||||
from: previous,
|
||||
to: context,
|
||||
diff: context - previous,
|
||||
chars: previousChars,
|
||||
tokens: previousTokens,
|
||||
})
|
||||
}
|
||||
if (isNumber(context)) previous = context
|
||||
previousChars = isNumber(step.toolOutput) ? step.toolOutput : 0
|
||||
previousTokens = isNumber(step.output) ? step.output : null
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
/** Gap between one step's start and the previous step's end. */
|
||||
function stepGaps(turn: PerfTurnMetrics): ReadonlyMap<number, number> {
|
||||
const gaps = new Map<number, number>()
|
||||
@@ -864,7 +829,7 @@ function metricsCells(value: MetricsValues, t: PerfTranslate): readonly MetricCe
|
||||
})
|
||||
}
|
||||
|
||||
/** Numeric cells after the context column, which renders its own delta. */
|
||||
/** Numeric cells after the context column. */
|
||||
function MetricCells({ cells, className }: MetricCellsProps): ReactElement {
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -877,18 +842,11 @@ function MetricCells({ cells, className }: MetricCellsProps): ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
/** Context column: value plus the signed change from the previous step. */
|
||||
function ContextCell({ value, delta, title, className }: ContextCellProps): ReactElement {
|
||||
/** Context column: the last measured prompt size of the row. */
|
||||
function ContextCell({ value, className }: ContextCellProps): ReactElement {
|
||||
return (
|
||||
<td className={className} title={title === '' ? undefined : title}>
|
||||
<td className={className}>
|
||||
<span className={css.num}>{formatTokensShort(value)}</span>
|
||||
{isNumber(delta) && delta !== 0
|
||||
? (
|
||||
<span className={cx(css.numDelta, delta > 0 ? css.deltaUp : css.deltaDown)}>
|
||||
{formatTokensDelta(delta)}
|
||||
</span>
|
||||
)
|
||||
: <span className={css.numDelta} />}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
@@ -1095,11 +1053,16 @@ function PromptCell({ turn, t, className, loadImage }: PromptCellProps): ReactEl
|
||||
: <ImagePromptPill first={first} images={images} loadImage={loadImage} t={t} />
|
||||
const source = turn.promptSource
|
||||
if (source !== null && source !== 'user') {
|
||||
// Automated turns explain what woke them instead of pretending to be a question.
|
||||
const label = triggerLabel(source, t)
|
||||
const text = turn.prompt ?? ''
|
||||
// Automated turns explain what woke them; team messages also name the sender.
|
||||
const base = triggerLabel(source, t)
|
||||
const sender = turn.promptSender
|
||||
const label = sender === null ? base : `${base} · ${sender}`
|
||||
const raw = turn.prompt ?? ''
|
||||
const text = sender === null
|
||||
? raw
|
||||
: raw.replace(/^(?:Team message from [^:]+:|Agent \S+ sent a message:)\s*/u, '')
|
||||
return (
|
||||
<td className={className} title={text === '' ? label : text}>
|
||||
<td className={className} title={raw === '' ? label : raw}>
|
||||
<span className={css.triggerPill}>{label}</span>
|
||||
{pill}
|
||||
{text === '' ? null : <span className={css.promptText}>{text}</span>}
|
||||
@@ -1127,7 +1090,12 @@ function PromptCell({ turn, t, className, loadImage }: PromptCellProps): ReactEl
|
||||
function triggerLabel(source: string, t: PerfTranslate): string {
|
||||
switch (source) {
|
||||
case 'agent-message': return t('trigger.agent-message')
|
||||
case 'team-message': return t('trigger.team-message')
|
||||
case 'subagent-settled': return t('trigger.subagent-settled')
|
||||
case 'schedule': return t('trigger.schedule')
|
||||
case 'webhook': return t('trigger.webhook')
|
||||
case 'tool-jobs': return t('trigger.tool-jobs')
|
||||
case 'cordis-host-runner': return t('trigger.cordis-host-runner')
|
||||
case 'goal': return t('trigger.goal')
|
||||
case 'runtime-context':
|
||||
case 'time-context': return t('trigger.context')
|
||||
@@ -1360,7 +1328,6 @@ function moreRows(
|
||||
/** Rendered rows of one turn: header, step-list controls, and step rows. */
|
||||
function stepRows(options: StepRowsOptions): ReactNode[] {
|
||||
const { turn, t, now, state, onToggleDetail, onControl } = options
|
||||
const deltas = contextDeltas(turn)
|
||||
const gaps = stepGaps(turn)
|
||||
const open = state.open ?? {}
|
||||
// Steps compare only with steps of the same turn.
|
||||
@@ -1372,24 +1339,8 @@ function stepRows(options: StepRowsOptions): ReactNode[] {
|
||||
const rows: ReactNode[] = []
|
||||
for (const step of shown) {
|
||||
const cells = metricsCells(step, t)
|
||||
const delta = deltas.get(step.step)
|
||||
const expanded = open[step.step] === true
|
||||
const hasDetail = step.calls.length > 0
|
||||
let contextTitle = ''
|
||||
if (delta !== undefined) {
|
||||
const lines = [
|
||||
t('context.delta', {
|
||||
from: formatTokensShort(delta.from),
|
||||
to: formatTokensShort(delta.to),
|
||||
delta: formatTokensDelta(delta.diff),
|
||||
}),
|
||||
]
|
||||
if (delta.chars > 0) lines.push(t('context.delta.chars', { chars: formatTokens(delta.chars) }))
|
||||
if (delta.tokens !== null && delta.tokens > 0) {
|
||||
lines.push(t('context.delta.output', { tokens: formatTokens(delta.tokens) }))
|
||||
}
|
||||
contextTitle = lines.join('\n')
|
||||
}
|
||||
const rowClass: (string | undefined)[] = []
|
||||
if (hasDetail) rowClass.push(css.stepRowClickable)
|
||||
if (step.toolErrors > 0) rowClass.push(css.stepRowError)
|
||||
@@ -1434,12 +1385,7 @@ function stepRows(options: StepRowsOptions): ReactNode[] {
|
||||
</td>
|
||||
<td className={css.promptCell} />
|
||||
{stepDidCell(step, t)}
|
||||
<ContextCell
|
||||
value={step.context}
|
||||
delta={delta === undefined ? null : delta.diff}
|
||||
title={contextTitle}
|
||||
className={css.stepMetric}
|
||||
/>
|
||||
<ContextCell value={step.context} className={css.stepMetric} />
|
||||
<MetricCells cells={cells} className={css.stepMetric} />
|
||||
<TimeCell
|
||||
value={step}
|
||||
@@ -1464,6 +1410,7 @@ function turnRows(options: TurnRowsOptions): ReactNode[] {
|
||||
const rows: ReactNode[] = [
|
||||
<tr
|
||||
key={`turn:${turn.turn}`}
|
||||
data-turn={turn.turn}
|
||||
className={css.turnRow}
|
||||
onClick={onToggle}
|
||||
title={t('turn.toggle.title')}
|
||||
@@ -1495,7 +1442,7 @@ function turnRows(options: TurnRowsOptions): ReactNode[] {
|
||||
</td>
|
||||
<PromptCell turn={turn} t={t} className={css.promptCellTurn} loadImage={loadImage} />
|
||||
{turnDidCell(turn, t)}
|
||||
<ContextCell value={turn.context} delta={null} title="" className={css.turnMetric} />
|
||||
<ContextCell value={turn.context} className={css.turnMetric} />
|
||||
<MetricCells cells={cells} className={css.turnMetric} />
|
||||
<TimeCell
|
||||
value={turn}
|
||||
@@ -1551,6 +1498,7 @@ export function PerfView(props: PerfViewProps): ReactElement {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exhausted, setExhausted] = useState(false)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [visibleTurns, setVisibleTurns] = useState<ReadonlySet<number>>(() => new Set())
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const loadingRef = useRef(false)
|
||||
const pendingPrepend = useRef<PendingPrepend | null>(null)
|
||||
@@ -1571,7 +1519,8 @@ export function PerfView(props: PerfViewProps): ReactElement {
|
||||
return () => { clearInterval(timer) }
|
||||
}, [hasRunning])
|
||||
// Turns compare only with the other turns in the window.
|
||||
const turnScale = turns.reduce((max, turn) => {
|
||||
const scaleTurns = visibleTurns.size === 0 ? turns : turns.filter(turn => visibleTurns.has(turn.turn))
|
||||
const turnScale = scaleTurns.reduce((max, turn) => {
|
||||
const span = totalSpan(turn, now)
|
||||
return span !== null && span > max ? span : max
|
||||
}, 0)
|
||||
@@ -1659,6 +1608,28 @@ export function PerfView(props: PerfViewProps): ReactElement {
|
||||
pendingPrepend.current = null
|
||||
element.scrollTop = pending.top + Math.max(0, element.scrollHeight - pending.height)
|
||||
})
|
||||
// Turn bars compare against the largest duration currently on screen, so an
|
||||
// oversized turn that scrolled away does not flatten the visible comparison.
|
||||
useEffect(() => {
|
||||
const element = scrollRef.current
|
||||
/* v8 ignore next -- the scrollport mounts with the first render. */
|
||||
if (element === null || typeof IntersectionObserver === 'undefined') return undefined
|
||||
const visible = new Set<number>()
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
let changed = false
|
||||
for (const entry of entries) {
|
||||
const turn = Number((entry.target as HTMLElement).dataset.turn)
|
||||
const had = visible.has(turn)
|
||||
if (entry.isIntersecting) visible.add(turn)
|
||||
else visible.delete(turn)
|
||||
if (had !== visible.has(turn)) changed = true
|
||||
}
|
||||
if (changed) setVisibleTurns(new Set(visible))
|
||||
}, { root: element })
|
||||
for (const row of element.querySelectorAll<HTMLElement>('tr[data-turn]')) observer.observe(row)
|
||||
return () => { observer.disconnect() }
|
||||
}, [turns.length])
|
||||
|
||||
const handleScroll = (event: UIEvent<HTMLDivElement>): void => {
|
||||
if (event.currentTarget.scrollTop > LOAD_OLDER_THRESHOLD) return
|
||||
void requestOlder()
|
||||
|
||||
@@ -10,7 +10,12 @@ export const zh = {
|
||||
'column.scope.title': '轮次行是该轮的合计;点一行展开或折叠它的每一步',
|
||||
'column.prompt': '触发',
|
||||
'column.prompt.title': '这一轮是由什么触发的;人写的提示词显示正文,自动触发显示来源标签',
|
||||
'trigger.agent-message': '团队消息',
|
||||
'trigger.agent-message': '收到任务消息',
|
||||
'trigger.team-message': '收到团队消息',
|
||||
'trigger.schedule': '定时触发',
|
||||
'trigger.webhook': 'Webhook',
|
||||
'trigger.tool-jobs': '后台任务',
|
||||
'trigger.cordis-host-runner': '插件触发',
|
||||
'trigger.subagent-settled': '子代理完成',
|
||||
'trigger.goal': '目标轮次',
|
||||
'trigger.context': '上下文唤醒',
|
||||
@@ -28,7 +33,7 @@ export const zh = {
|
||||
'column.ttft.title': '步开始到第一个输出片段(TTFT)',
|
||||
'column.model': '模型用时',
|
||||
'column.model.title': '步开始到收到完整回复(含首 token 延迟)',
|
||||
'column.tps': '输出速度 (tok/s)',
|
||||
'column.tps': '输出速度',
|
||||
'column.tps.title': '输出词数 ÷ 生成秒数(TPS,tok/s)',
|
||||
'column.tool': '工具调用用时',
|
||||
'column.tool.title': '该步内每次工具调用到拿到结果的耗时之和;还没有结果的调用不计',
|
||||
@@ -93,9 +98,6 @@ export const zh = {
|
||||
'detail.truncated': '内容过长,这里只保留了前 {chars} 字符',
|
||||
'tool.failed': '失败 {count}',
|
||||
'tool.failed.title': '该步有 {count} 次工具调用返回错误结果',
|
||||
'context.delta': '上一步 {from} · 本步 {to}({delta})',
|
||||
'context.delta.chars': '· 上一步工具输出 {chars} 字符',
|
||||
'context.delta.output': '· 上一步模型输出 {tokens} 词',
|
||||
'bar.ttft': '首 token {value}',
|
||||
'bar.gen': '生成 {value}',
|
||||
'bar.tool': '工具调用 {value}',
|
||||
@@ -143,7 +145,12 @@ export const en: Record<PerfKey, string> = {
|
||||
'column.scope.title': 'A turn row holds the turn total; click a row to expand or collapse its steps',
|
||||
'column.prompt': 'Trigger',
|
||||
'column.prompt.title': 'What opened this turn: a human prompt shows its text, automation shows a source badge',
|
||||
'trigger.agent-message': 'Team message',
|
||||
'trigger.agent-message': 'Task message',
|
||||
'trigger.team-message': 'Team message',
|
||||
'trigger.schedule': 'Scheduled',
|
||||
'trigger.webhook': 'Webhook',
|
||||
'trigger.tool-jobs': 'Background job',
|
||||
'trigger.cordis-host-runner': 'Plugin run',
|
||||
'trigger.subagent-settled': 'Subagent finished',
|
||||
'trigger.goal': 'Goal round',
|
||||
'trigger.context': 'Context wake',
|
||||
@@ -161,7 +168,7 @@ export const en: Record<PerfKey, string> = {
|
||||
'column.ttft.title': 'step start to the first output fragment (TTFT)',
|
||||
'column.model': 'Model time',
|
||||
'column.model.title': 'step start to the settled reply (includes the first-token delay)',
|
||||
'column.tps': 'Output speed (tok/s)',
|
||||
'column.tps': 'Output speed',
|
||||
'column.tps.title': 'output tokens ÷ generation seconds (TPS, tok/s)',
|
||||
'column.tool': 'Tool time',
|
||||
'column.tool.title': 'sum of every tool call up to its result inside the step; calls without a result are excluded',
|
||||
@@ -226,9 +233,6 @@ export const en: Record<PerfKey, string> = {
|
||||
'detail.truncated': 'Too long: only the first {chars} characters are kept',
|
||||
'tool.failed': '{count} failed',
|
||||
'tool.failed.title': '{count} tool calls in this step returned an error result',
|
||||
'context.delta': 'previous {from} · this step {to} ({delta})',
|
||||
'context.delta.chars': '· previous step tool output {chars} chars',
|
||||
'context.delta.output': '· previous step model output {tokens} tokens',
|
||||
'bar.ttft': 'first token {value}',
|
||||
'bar.gen': 'generation {value}',
|
||||
'bar.tool': 'tool calls {value}',
|
||||
@@ -263,7 +267,12 @@ export const ja: Record<PerfKey, string> = {
|
||||
'column.scope.title': 'ターン行はそのターンの合計です。行をクリックすると各ステップを展開または折りたたみます',
|
||||
'column.prompt': 'トリガー',
|
||||
'column.prompt.title': 'このターンを開始したものを表示します。人間のプロンプトは本文、自動トリガーはタグを表示します',
|
||||
'trigger.agent-message': 'チームメッセージ',
|
||||
'trigger.agent-message': 'タスクメッセージ受信',
|
||||
'trigger.team-message': 'チームメッセージ受信',
|
||||
'trigger.schedule': 'スケジュール実行',
|
||||
'trigger.webhook': 'Webhook',
|
||||
'trigger.tool-jobs': 'バックグラウンドジョブ',
|
||||
'trigger.cordis-host-runner': 'プラグイン実行',
|
||||
'trigger.subagent-settled': 'サブエージェント完了',
|
||||
'trigger.goal': 'ゴールラウンド',
|
||||
'trigger.context': 'コンテキストによる再開',
|
||||
@@ -281,7 +290,7 @@ export const ja: Record<PerfKey, string> = {
|
||||
'column.ttft.title': 'ステップ開始から最初の出力断片まで(TTFT)',
|
||||
'column.model': 'モデル時間',
|
||||
'column.model.title': 'ステップ開始から応答完了まで(最初のトークン遅延を含む)',
|
||||
'column.tps': '出力速度 (tok/s)',
|
||||
'column.tps': '出力速度',
|
||||
'column.tps.title': '出力トークン ÷ 生成秒数(TPS、tok/s)',
|
||||
'column.tool': 'ツール時間',
|
||||
'column.tool.title': 'ステップ内の各ツール呼び出しから結果受信までの時間の合計。結果のない呼び出しは含みません',
|
||||
@@ -346,9 +355,6 @@ export const ja: Record<PerfKey, string> = {
|
||||
'detail.truncated': '長すぎるため、先頭 {chars} 文字のみ保持しています',
|
||||
'tool.failed': '{count} 失敗',
|
||||
'tool.failed.title': 'このステップで {count} 件のツール呼び出しがエラー結果を返しました',
|
||||
'context.delta': '前回 {from} · 今回 {to}({delta})',
|
||||
'context.delta.chars': '· 前回のツール出力 {chars} 文字',
|
||||
'context.delta.output': '· 前回のモデル出力 {tokens} トークン',
|
||||
'bar.ttft': '最初のトークン {value}',
|
||||
'bar.gen': '生成 {value}',
|
||||
'bar.tool': 'ツール呼び出し {value}',
|
||||
|
||||
@@ -18,15 +18,21 @@ interface TurnTrigger {
|
||||
readonly text: string
|
||||
readonly images: readonly ImageAttachmentRef[]
|
||||
readonly source: string
|
||||
readonly sender: string | null
|
||||
}
|
||||
|
||||
/** Human prompts outrank automation context; ties keep the first message. */
|
||||
function triggerRank(source: string): number {
|
||||
switch (source) {
|
||||
case 'user': return 4
|
||||
case 'agent-message': return 3
|
||||
case 'team-message': return 3
|
||||
case 'agent-message': return 2
|
||||
case 'subagent-settled': return 2
|
||||
case 'goal': return 1
|
||||
case 'schedule':
|
||||
case 'webhook':
|
||||
case 'tool-jobs':
|
||||
case 'cordis-host-runner': return 1
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
@@ -190,6 +196,7 @@ export function aggregateTurn(turn: number, steps: readonly PerfStepMetrics[]):
|
||||
prompt: null,
|
||||
promptImages: NO_IMAGES,
|
||||
promptSource: null,
|
||||
promptSender: null,
|
||||
answer,
|
||||
}
|
||||
}
|
||||
@@ -230,6 +237,7 @@ export function buildTurns(nodes: Iterable<PerfViewNode>): PerfTurnMetrics[] {
|
||||
text: data.prompt ?? '',
|
||||
images: data.images,
|
||||
source: data.source,
|
||||
sender: data.sender,
|
||||
})
|
||||
}
|
||||
continue
|
||||
@@ -297,6 +305,7 @@ export function buildTurns(nodes: Iterable<PerfViewNode>): PerfTurnMetrics[] {
|
||||
prompt: prompt === undefined || prompt.text === '' ? null : prompt.text,
|
||||
promptImages: prompt === undefined ? NO_IMAGES : prompt.images,
|
||||
promptSource: prompt === undefined ? null : prompt.source,
|
||||
promptSender: prompt === undefined ? null : prompt.sender,
|
||||
})
|
||||
})
|
||||
return turns
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface PerfStepState {
|
||||
readonly closeKind: string | null
|
||||
/** Producer source kind of the trigger prompt Context. */
|
||||
readonly trigger: string | null
|
||||
/** Sender agent name parsed from a team/agent message, or null. */
|
||||
readonly triggerSender: string | null
|
||||
/** Trimmed one-message answer text, for the Did column preview. */
|
||||
readonly answer: string | null
|
||||
}
|
||||
@@ -154,6 +156,8 @@ export interface PerfTurnMetrics {
|
||||
readonly promptImages: readonly ImageAttachmentRef[]
|
||||
/** Producer source kind that opened the turn (`user`, `agent-message`, ...). */
|
||||
readonly promptSource: string | null
|
||||
/** Sender agent name of an automated trigger, or null. */
|
||||
readonly promptSender: string | null
|
||||
/** Last answer preview measured in the turn. */
|
||||
readonly answer: string | null
|
||||
}
|
||||
@@ -176,6 +180,8 @@ export type PerfContribution =
|
||||
readonly images: readonly ImageAttachmentRef[]
|
||||
/** Producer source kind of the triggering user message. */
|
||||
readonly source: string
|
||||
/** Sender agent name of a team/agent message, or null. */
|
||||
readonly sender: string | null
|
||||
}
|
||||
| {
|
||||
readonly kind: 'end'
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { PerfStepState, PerfViewNode } from './perf-contract.ts'
|
||||
import {
|
||||
CALL_LIMIT, fallbackState, foldEvent, isCodeMode, newState, promptOf, stepMetrics,
|
||||
CALL_LIMIT, fallbackState, foldEvent, isCodeMode, newState, promptOf, stepMetrics, triggerSenderOf,
|
||||
} from './perf-fold.ts'
|
||||
|
||||
/** Target name shared by every Definition and the view builder. */
|
||||
@@ -136,6 +136,7 @@ const promptDefinition: ConversationNodeDefinition<PerfStepState> = {
|
||||
prompt: prompt.text,
|
||||
images: prompt.images,
|
||||
trigger: match.event.data.source.kind,
|
||||
triggerSender: triggerSenderOf(match.event.data),
|
||||
}
|
||||
},
|
||||
update: context => context.state,
|
||||
@@ -150,6 +151,7 @@ const promptDefinition: ConversationNodeDefinition<PerfStepState> = {
|
||||
prompt: state.prompt,
|
||||
images: state.images,
|
||||
source: state.trigger ?? 'other',
|
||||
sender: state.triggerSender,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -254,6 +254,24 @@ function isTokenDelta(chunk: StreamChunk): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
const TEAM_MESSAGE_PREFIX = /^Team message from ([^:]+):/u
|
||||
const AGENT_MESSAGE_PREFIX = /^Agent (\S+) sent a message:/u
|
||||
|
||||
/** Sender agent name of a teammate/agent notification, parsed from its body prefix. */
|
||||
export function triggerSenderOf(message: UserMessage): string | null {
|
||||
const kind: string = message.source.kind
|
||||
if (kind !== 'agent-message' && kind !== 'team-message') return null
|
||||
let text = ''
|
||||
for (const block of message.content) {
|
||||
if (block.type === 'text') text += block.text
|
||||
}
|
||||
const team = TEAM_MESSAGE_PREFIX.exec(text)
|
||||
/* v8 ignore next -- a matched capture group is always present. */
|
||||
if (team !== null) return team[1]?.trim() ?? null
|
||||
const agent = AGENT_MESSAGE_PREFIX.exec(text)
|
||||
return agent?.[1] ?? null
|
||||
}
|
||||
|
||||
/** Answer characters kept for the one-line Did preview. */
|
||||
const ANSWER_LIMIT = 400
|
||||
|
||||
@@ -307,6 +325,7 @@ export function newState(
|
||||
closed: false,
|
||||
closeKind: null,
|
||||
trigger: null,
|
||||
triggerSender: null,
|
||||
answer: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function formatChars(value: number | null): string {
|
||||
* @returns one-decimal speed, or the dash placeholder.
|
||||
*/
|
||||
export function formatTps(value: number | null): string {
|
||||
return isNumber(value) ? value.toFixed(1) : DASH
|
||||
return isNumber(value) ? `${value.toFixed(1)} tok/s` : DASH
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,17 +45,17 @@
|
||||
|
||||
/* Scope | question | did | seven metrics | total time; fixed widths keep the
|
||||
measurement columns visible instead of letting the did cell push them out. */
|
||||
.table col:nth-child(1) { width: 10%; }
|
||||
.table col:nth-child(2) { width: 16%; }
|
||||
.table col:nth-child(3) { width: 13%; }
|
||||
.table col:nth-child(4) { width: 6%; }
|
||||
.table col:nth-child(5) { width: 6%; }
|
||||
.table col:nth-child(6) { width: 6%; }
|
||||
.table col:nth-child(7) { width: 6.5%; }
|
||||
.table col:nth-child(8) { width: 6.5%; }
|
||||
.table col:nth-child(1) { width: 15%; }
|
||||
.table col:nth-child(2) { width: 17%; }
|
||||
.table col:nth-child(3) { width: 15%; }
|
||||
.table col:nth-child(4) { width: 5%; }
|
||||
.table col:nth-child(5) { width: 5%; }
|
||||
.table col:nth-child(6) { width: 5%; }
|
||||
.table col:nth-child(7) { width: 5.5%; }
|
||||
.table col:nth-child(8) { width: 5.5%; }
|
||||
.table col:nth-child(9) { width: 6.5%; }
|
||||
.table col:nth-child(10) { width: 6.5%; }
|
||||
.table col:nth-child(11) { width: 17%; }
|
||||
.table col:nth-child(10) { width: 6%; }
|
||||
.table col:nth-child(11) { width: 14.5%; }
|
||||
|
||||
.thLeft,
|
||||
.th {
|
||||
@@ -101,8 +101,8 @@
|
||||
|
||||
.turnCell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 600;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.turnMetric {
|
||||
@@ -530,15 +530,17 @@
|
||||
|
||||
/* Time composition: turn bars are tall and outlined, step bars short and bare. */
|
||||
.timeWrap {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.barTurn {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
width: 150px;
|
||||
flex: 1;
|
||||
min-width: 40px;
|
||||
height: 14px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 2px;
|
||||
@@ -549,7 +551,8 @@
|
||||
|
||||
.barStep {
|
||||
display: inline-block;
|
||||
width: 104px;
|
||||
flex: 1;
|
||||
min-width: 32px;
|
||||
height: 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
'team-message': { readonly kind: 'team-message'; readonly senderName: string }
|
||||
'agent-message': { readonly kind: 'agent-message' }
|
||||
}
|
||||
}
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { SessionEventLike } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
@@ -19,7 +26,7 @@ import type {} from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import { SessionSeq, type SessionEventMap, type SessionEventType } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types'
|
||||
import {
|
||||
CALL_LIMIT, fallbackState, foldEvent, isCodeMode, newState, promptOf, stepMetrics, toolSpanUnion,
|
||||
CALL_LIMIT, fallbackState, foldEvent, isCodeMode, newState, promptOf, stepMetrics, toolSpanUnion, triggerSenderOf,
|
||||
} from '../src/client/perf-fold.ts'
|
||||
import {
|
||||
DASH, barShare, formatChars, formatClock, formatDateTime, formatDateTimeShort, formatMs, formatPercent,
|
||||
@@ -319,8 +326,8 @@ describe('display formatting boundaries', () => {
|
||||
expect(formatChars(1_234)).toBe('1.2K')
|
||||
expect(formatTps(null)).toBe(DASH)
|
||||
expect(formatTps(Number.NaN)).toBe(DASH)
|
||||
expect(formatTps(0)).toBe('0.0')
|
||||
expect(formatTps(268.94)).toBe('268.9')
|
||||
expect(formatTps(0)).toBe('0.0 tok/s')
|
||||
expect(formatTps(268.94)).toBe('268.9 tok/s')
|
||||
expect(formatPercent(null)).toBe(DASH)
|
||||
expect(formatPercent(0)).toBe('0.00%')
|
||||
expect(formatPercent(0.962)).toBe('96.20%')
|
||||
@@ -713,6 +720,29 @@ describe('prompt helper', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('trigger sender helper', () => {
|
||||
it('names the teammate that sent a framed message', () => {
|
||||
expect(triggerSenderOf(userMessage([imageBlock(), textBlock('Team message from hfai-deploy: update')], {
|
||||
kind: 'team-message', senderName: 'hfai-deploy',
|
||||
}))).toBe('hfai-deploy')
|
||||
expect(triggerSenderOf(userMessage([textBlock('Team message from stack-rebase : go')], {
|
||||
kind: 'team-message', senderName: 'stack-rebase',
|
||||
}))).toBe('stack-rebase')
|
||||
})
|
||||
|
||||
it('parses the agent id from a direct agent message', () => {
|
||||
expect(triggerSenderOf(userMessage([textBlock('Agent abc-123 sent a message: hi')], {
|
||||
kind: 'agent-message',
|
||||
}))).toBe('abc-123')
|
||||
})
|
||||
|
||||
it('returns nothing without a framed sender', () => {
|
||||
expect(triggerSenderOf(userMessage([textBlock('Team message from hfai-deploy: x')], { kind: 'user' }))).toBeNull()
|
||||
expect(triggerSenderOf(userMessage([textBlock('hello')], { kind: 'agent-message' }))).toBeNull()
|
||||
expect(triggerSenderOf(userMessage([textBlock('Agent noverb')], { kind: 'agent-message' }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool call folding', () => {
|
||||
it('records a call before any step boundary and tallies its tool', () => {
|
||||
const state = defined(foldEvent(undefined, toolCall(5, 2, 'c1', 'grep', '{"pattern":"x"}', 1_000)))
|
||||
|
||||
@@ -96,7 +96,7 @@ function promptNode(
|
||||
target: 'perf',
|
||||
anchorSeq: 1,
|
||||
location: { kind: 'unresolved' },
|
||||
data: { kind: 'prompt', turn, prompt, images, source },
|
||||
data: { kind: 'prompt', turn, prompt, images, source, sender: null },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +373,22 @@ describe('turn grouping', () => {
|
||||
expect(team[0]?.prompt).toBe('team message')
|
||||
expect(team[0]?.promptSource).toBe('agent-message')
|
||||
|
||||
const teamMessage = buildTurns([
|
||||
stepNode(metricsOf({ turn: 8, step: 1, startTime: 800 })),
|
||||
promptNode(8, 'team body', [], 'team-message'),
|
||||
promptNode(8, 'agent body', [], 'agent-message'),
|
||||
])
|
||||
expect(teamMessage[0]?.promptSource).toBe('team-message')
|
||||
|
||||
const automation = ['schedule', 'webhook', 'tool-jobs', 'cordis-host-runner'].map((source, index) => {
|
||||
const turn = buildTurns([
|
||||
stepNode(metricsOf({ turn: 20 + index, step: 1, startTime: 2_000 + index })),
|
||||
promptNode(20 + index, source, [], source),
|
||||
])
|
||||
return turn[0]?.promptSource
|
||||
})
|
||||
expect(automation).toEqual(['schedule', 'webhook', 'tool-jobs', 'cordis-host-runner'])
|
||||
|
||||
const settled = buildTurns([
|
||||
stepNode(metricsOf({ turn: 3, step: 1, startTime: 300 })),
|
||||
promptNode(3, 'subagent done', [], 'subagent-settled'),
|
||||
|
||||
@@ -515,7 +515,7 @@ describe('snapshot builder', () => {
|
||||
expect(builder.apply({ upserts: [], timeline: { turnOrder: [], turns: new Map() } })).toBe(first)
|
||||
const upserted = builder.apply({
|
||||
upserts: [
|
||||
node('perf-prompt:1:0', { kind: 'prompt', turn: 1, prompt: '问', images: [IMAGE], source: 'user' }),
|
||||
node('perf-prompt:1:0', { kind: 'prompt', turn: 1, prompt: '问', images: [IMAGE], source: 'user', sender: null }),
|
||||
node('perf-step:1:1', { kind: 'step', metrics: measuredStep(1, 1, 1_000, 1_800) }),
|
||||
node('perf-turn-end:1:100000', { kind: 'end', turn: 1, closeKind: 'completed', endTime: 1_800 }),
|
||||
],
|
||||
@@ -703,7 +703,7 @@ describe('conversation Definitions', () => {
|
||||
const node = prompt.buildViewNode!({
|
||||
...contextOf(prompt, state, [match], match),
|
||||
}) as PerfViewNode
|
||||
expect(node.data).toEqual({ kind: 'prompt', turn: 9, prompt: '继续', images: [IMAGE], source: 'user' })
|
||||
expect(node.data).toEqual({ kind: 'prompt', turn: 9, prompt: '继续', images: [IMAGE], source: 'user', sender: null })
|
||||
const context: ConversationNodeContext<PerfStepState> & { readonly state: PerfStepState } = {
|
||||
...contextOf(prompt, state, [match], match),
|
||||
state,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { PerfView } from '../src/client/PerfView.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import css from '../src/client/perf.module.css'
|
||||
import {
|
||||
formatChars, formatClock, formatDateTime, formatDateTimeShort, formatMs, formatPercent, formatTokens,
|
||||
barShare, formatChars, formatClock, formatDateTime, formatDateTimeShort, formatMs, formatPercent, formatTokens,
|
||||
formatTokensShort, formatTps,
|
||||
} from '../src/client/perf-format.ts'
|
||||
import type {
|
||||
@@ -136,6 +136,7 @@ function turn(overrides: Partial<PerfTurnMetrics> = {}): PerfTurnMetrics {
|
||||
prompt: null,
|
||||
promptImages: [],
|
||||
promptSource: null,
|
||||
promptSender: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -580,6 +581,22 @@ describe('turn rows', () => {
|
||||
expect(stillRunning[10]?.textContent).toBe(formatMs(1_000))
|
||||
})
|
||||
|
||||
it('names the sending agent on team-message triggers', () => {
|
||||
mount({
|
||||
turns: [turn({
|
||||
prompt: 'Team message from hfai-deploy: P1 已 rebase 完成',
|
||||
promptSource: 'team-message',
|
||||
promptSender: 'hfai-deploy',
|
||||
})],
|
||||
stepCount: 0,
|
||||
})
|
||||
const cell = cellsOf(rowWithPrefix(say('turn.label', { turn: 1 })))[1] as HTMLTableCellElement
|
||||
expect(cell.textContent).toContain(`${say('trigger.team-message')} · hfai-deploy`)
|
||||
expect(cell.textContent).toContain('P1 已 rebase 完成')
|
||||
expect(cell.textContent).not.toContain('Team message from')
|
||||
expect(cell.getAttribute('title')).toBe('Team message from hfai-deploy: P1 已 rebase 完成')
|
||||
})
|
||||
|
||||
it('summarizes turn tools with pills, overflow, errors, and output size', () => {
|
||||
mount({
|
||||
turns: [turn({
|
||||
@@ -637,7 +654,7 @@ describe('turn rows', () => {
|
||||
})
|
||||
|
||||
it('labels automated turn triggers instead of leaving the question empty', () => {
|
||||
const sources = ['agent-message', 'subagent-settled', 'goal', 'runtime-context', 'time-context', 'mystery']
|
||||
const sources = ['agent-message', 'team-message', 'subagent-settled', 'goal', 'schedule', 'webhook', 'tool-jobs', 'cordis-host-runner', 'runtime-context', 'time-context', 'mystery']
|
||||
mount({
|
||||
turns: sources.map((source, index) => turn({
|
||||
turn: index + 1,
|
||||
@@ -648,8 +665,13 @@ describe('turn rows', () => {
|
||||
})
|
||||
const labels: Readonly<Record<string, string>> = {
|
||||
'agent-message': say('trigger.agent-message'),
|
||||
'team-message': say('trigger.team-message'),
|
||||
'subagent-settled': say('trigger.subagent-settled'),
|
||||
'goal': say('trigger.goal'),
|
||||
'schedule': say('trigger.schedule'),
|
||||
'webhook': say('trigger.webhook'),
|
||||
'tool-jobs': say('trigger.tool-jobs'),
|
||||
'cordis-host-runner': say('trigger.cordis-host-runner'),
|
||||
'runtime-context': say('trigger.context'),
|
||||
'time-context': say('trigger.context'),
|
||||
'mystery': say('trigger.other'),
|
||||
@@ -697,6 +719,56 @@ describe('turn rows', () => {
|
||||
expect(absent[5]?.getAttribute('title')).toBe(say('column.cache.title'))
|
||||
})
|
||||
|
||||
it('scales turn bars against the turns visible on screen', () => {
|
||||
interface FakeEntry { readonly target: Element; readonly isIntersecting: boolean }
|
||||
class FakeObserver {
|
||||
static current: FakeObserver | undefined
|
||||
readonly observed: Element[] = []
|
||||
readonly disconnect = vi.fn()
|
||||
readonly callback: (entries: readonly FakeEntry[]) => void
|
||||
constructor(callback: (entries: readonly FakeEntry[]) => void) {
|
||||
this.callback = callback
|
||||
FakeObserver.current = this
|
||||
}
|
||||
observe(element: Element): void { this.observed.push(element) }
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', FakeObserver)
|
||||
const base = 1_700_000_000_000
|
||||
mount({
|
||||
turns: [
|
||||
turn({ turn: 1, startTime: base, wall: 1_000 }),
|
||||
turn({ turn: 2, startTime: base, wall: 4_000 }),
|
||||
],
|
||||
stepCount: 2,
|
||||
})
|
||||
const barWidth = (turnNumber: number): string => {
|
||||
const row = rowWithPrefix(say('turn.label', { turn: turnNumber }))
|
||||
const fill = row.querySelector<HTMLElement>('[class*="barFill"]')
|
||||
return fill?.style.width ?? ''
|
||||
}
|
||||
// The observer starts with no visible rows: the whole loaded window scales the bars.
|
||||
expect(barWidth(1)).toBe(`${Math.round(barShare(1_000, 4_000) * 100)}%`)
|
||||
const observer = FakeObserver.current
|
||||
expect(observer?.observed).toHaveLength(2)
|
||||
const row1 = rowWithPrefix(say('turn.label', { turn: 1 }))
|
||||
const row2 = rowWithPrefix(say('turn.label', { turn: 2 }))
|
||||
act(() => {
|
||||
observer!.callback([
|
||||
{ target: row1, isIntersecting: true },
|
||||
{ target: row2, isIntersecting: false },
|
||||
])
|
||||
})
|
||||
// Visible turn 1 becomes the scale, so its bar fills the track.
|
||||
expect(barWidth(1)).toBe('100%')
|
||||
act(() => {
|
||||
observer!.callback([{ target: row1, isIntersecting: true }])
|
||||
})
|
||||
expect(barWidth(1)).toBe('100%')
|
||||
cleanup()
|
||||
expect(observer?.disconnect).toHaveBeenCalled()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('draws the bar tool segment from the non-overlapping span and keeps the column sum', () => {
|
||||
mount({
|
||||
turns: [turn({ tool: 900, toolWall: 300, wall: 1_000, ttft: 100, model: 400 })],
|
||||
@@ -802,7 +874,7 @@ describe('step rows', () => {
|
||||
expect(cellsOf(rowWithPrefix(say('turn.label', { turn: 1 })))[2]?.textContent).toBe(say('did.answer'))
|
||||
})
|
||||
|
||||
it('shows context deltas and the gap since the previous step', () => {
|
||||
it('shows the plain context value and the gap since the previous step', () => {
|
||||
const steps = [
|
||||
step({ step: 1, context: 1_000, toolOutput: 500, output: 100, startTime: BASE, endTime: BASE + 1_000 }),
|
||||
step({ step: 2, context: 1_500, toolOutput: 0, output: 200, startTime: BASE + 5_000, endTime: BASE + 6_000 }),
|
||||
@@ -813,12 +885,8 @@ describe('step rows', () => {
|
||||
mount(turnSnapshot(steps))
|
||||
|
||||
const second = cellsOf(stepRow(2))
|
||||
expect(second[3]?.textContent).toContain('+0.50K')
|
||||
expect(second[3]?.getAttribute('title')).toBe([
|
||||
say('context.delta', { from: formatTokensShort(1_000), to: formatTokensShort(1_500), delta: '+0.50K' }),
|
||||
say('context.delta.chars', { chars: formatTokens(500) }),
|
||||
say('context.delta.output', { tokens: formatTokens(100) }),
|
||||
].join('\n'))
|
||||
expect(second[3]?.textContent).toBe(formatTokensShort(1_500))
|
||||
expect(second[3]?.getAttribute('title')).toBeNull()
|
||||
expect(timeTitle(stepRow(2))).toContain(say('bar.gap', { value: formatMs(4_000) }))
|
||||
|
||||
const third = cellsOf(stepRow(3))
|
||||
@@ -839,12 +907,8 @@ describe('step rows', () => {
|
||||
expect(timeTitle(stepRow(4))).not.toContain('距上一步')
|
||||
|
||||
const fifth = cellsOf(stepRow(5))
|
||||
expect(fifth[3]?.textContent).toContain('−0.60K')
|
||||
expect(fifth[3]?.getAttribute('title')).toBe(say('context.delta', {
|
||||
from: formatTokensShort(1_500),
|
||||
to: formatTokensShort(900),
|
||||
delta: '−0.60K',
|
||||
}))
|
||||
expect(fifth[3]?.textContent).toBe(formatTokensShort(900))
|
||||
expect(fifth[3]?.getAttribute('title')).toBeNull()
|
||||
})
|
||||
|
||||
it('summarizes step tools with line, edit, and character statistics', () => {
|
||||
|
||||
Reference in new issue
Block a user