圆桌会商各步接入流式回答速度指示器,复用主聊天 SpeedPill 与全局开关。

新增 DialogueSpeedMeter 与 useDialogueSpeedMetrics,在 Step1/2/3 气泡底部展示 tok/s 与耗时。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-06-12 15:43:58 +08:00
1 parent 728a4b1170
commit b58e731c19
7 files changed
+235 -2

No files matched your search

@@ -23,7 +23,7 @@ function classifySpeed(tps: number): { color: string; label: string } {
return { color: "#ef4444", label: "缓慢" };
}
function formatDuration(ms: number): string {
export function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
const mins = Math.floor(ms / 60_000);
@@ -31,7 +31,7 @@ function formatDuration(ms: number): string {
return `${mins}m ${secs}s`;
}
function SpeedPill({ metric }: { metric: MessageMetric }) {
export function SpeedPill({ metric }: { metric: MessageMetric }) {
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec);
const display =
@@ -0,0 +1,27 @@
/**
* 圆桌会商气泡底部的「回答速度指示器」—— 复用主聊天的 `SpeedPill`(同款图标 +
* tok/s 数值 + 绿→红渐变条)与 `formatDuration`,外壳套圆桌的细分隔线 + 小字号,
* 让多智能体会商页的每条模型发言与主页面观感一致。
*
* 仅展示用:是否渲染由调用方按 `streamSpeedMeterEnabled` 开关 + 是否有度量决定
* (与主页面共用同一个全局开关)。`durationMs <= 0`(尚无可计时数据)时不渲染。
*/
import {
SpeedPill,
formatDuration,
} from "@/components/workspace/messages/message-token-usage";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
export function DialogueSpeedMeter({ metric }: { metric: MessageMetric }) {
if (metric.durationMs <= 0) {
return null;
}
return (
<div className="roundtable-chat-meta mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-slate-150 pt-1.5 text-slate-400">
<SpeedPill metric={metric} />
<span className="font-mono tabular-nums">
{formatDuration(metric.durationMs)}
</span>
</div>
);
}
@@ -35,7 +35,10 @@ function formatFileSize(bytes: number): string {
}
import { Button } from "@/components/ui/button";
import { useLocalSettings } from "@/core/settings";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpeedMeter";
import {
clarificationKey,
IntentClarificationCard,
@@ -51,6 +54,7 @@ import {
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants";
@@ -108,6 +112,11 @@ export function Step1Panel(props: Step1PanelProps) {
const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } =
useAutoScroll(step1ScrollRef, [interactiveMessages]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const messageMetrics = useDialogueSpeedMetrics(interactiveMessages);
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
const [inputText, setInputText] = useState<string>("");
// 待发送的本地附件(点发送时连同文本一起上传)。
@@ -165,6 +174,7 @@ export function Step1Panel(props: Step1PanelProps) {
<Step1Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
intentReady={intentReady}
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
intentClarificationSelections={intentClarificationSelections}
@@ -338,6 +348,11 @@ export function Step1Panel(props: Step1PanelProps) {
*/
interface Step1MessageProps {
msg: InteractiveMessage;
/**
* 本条气泡的「回答速度」度量(tok/s + 时长)。仅对流式过的智能体发言有值;
* 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。
*/
metric?: MessageMetric;
intentReady: object | null;
pendingIntentClarificationMsgId: number | null;
intentClarificationSelections: Map<string, IntentClarificationSelection>;
@@ -354,6 +369,7 @@ interface Step1MessageProps {
function Step1Message({
msg,
metric,
intentReady,
pendingIntentClarificationMsgId,
intentClarificationSelections,
@@ -520,6 +536,8 @@ function Step1Message({
)}
</div>
)}
{metric && <DialogueSpeedMeter metric={metric} />}
</MessageBubble>
);
}
@@ -47,6 +47,10 @@ import {
/** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */
const SHOW_BACKGROUND_SUSPEND = true;
import { useLocalSettings } from "@/core/settings";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpeedMeter";
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -62,6 +66,7 @@ import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsC
import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import type {
AgentConfig,
ParallelSeatView,
@@ -189,6 +194,12 @@ export function Step2Panel(props: Step2PanelProps) {
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
// 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(step2RoundtableDialogues);
// 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换,
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
@@ -456,6 +467,7 @@ export function Step2Panel(props: Step2PanelProps) {
{msg ? (
<Step2Message
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
@@ -476,6 +488,7 @@ export function Step2Panel(props: Step2PanelProps) {
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
@@ -687,6 +700,11 @@ export function Step2Panel(props: Step2PanelProps) {
*/
export interface Step2MessageProps {
msg: Step2Dialogue;
/**
* 本条气泡的「回答速度」度量(tok/s + 时长)。仅对流式过的模型发言有值;
* 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。
*/
metric?: MessageMetric;
/** 把派活 chip 的 agent id 解析成中文席位名。 */
resolveAgentName?: (idOrName: string) => string;
onArtifactStepClick?: (
@@ -698,6 +716,7 @@ export interface Step2MessageProps {
export function Step2Message({
msg,
metric,
resolveAgentName,
onArtifactStepClick,
onFileCardClick,
@@ -749,6 +768,8 @@ export function Step2Message({
disabled={!msg.agentThreadId}
/>
)}
{metric && <DialogueSpeedMeter metric={metric} />}
</MessageBubble>
);
}
@@ -30,6 +30,7 @@ import {
} from "lucide-react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import {
buildArtifactDownloadUrl,
@@ -45,6 +46,7 @@ import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2Sandbo
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import {
useStep3Report,
type ReportIntent,
@@ -480,6 +482,10 @@ function AgentProgressPanel({
const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
const [revisionText, setRevisionText] = useState("");
const handleRevisionSubmit = (e: FormEvent<HTMLFormElement>) => {
@@ -548,6 +554,7 @@ function AgentProgressPanel({
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
@@ -31,6 +31,7 @@ import {
} from "lucide-react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import {
buildArtifactDownloadUrl,
@@ -54,6 +55,7 @@ import { markdownComponents } from "@/roundtable-planning/components/markdown-co
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
@@ -573,6 +575,10 @@ function AgentProgressPanel({
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
const [askText, setAskText] = useState("");
// 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。
const [askFiles, setAskFiles] = useState<File[]>([]);
@@ -655,6 +661,7 @@ function AgentProgressPanel({
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
@@ -0,0 +1,153 @@
/**
* 圆桌各步消息气泡的「回答速度指示器」度量 —— 对齐主聊天
* `components/workspace/messages/use-message-metrics.ts` 的算法。Step 1 的
* `InteractiveMessage`、Step 2 / Step 3 的 `Step2Dialogue` 在「id + content +
* streaming」上同形,故本 hook 只依赖这三字段的最小形状,三步通用:
*
* - 圆桌气泡没有 `usage_metadata`,统一走字符估算(≈ 2 字符 / token)。
* - 以「这条气泡是否 **流式过**」作为「它是不是真正的模型生成」的判据:
* 只有 `streaming === true` 出现过的气泡才建度量项。用户提问 / 系统播报 /
* 澄清回复这类一次性整条推入、从不流式的气泡天然不会有速度胶囊。
* - 流式中用滑动窗口算实时 tok/s;流式结束(streaming 翻 false)冻结时长与速率。
*
* 返回 `Map<id, MessageMetric>`,复用主聊天的 `MessageMetric` 形状,
* 以便直接喂给同一个 `SpeedPill`。
*/
import { useEffect, useRef, useState } from "react";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
/** 本 hook 需要的最小气泡形状(Step 1/2/3 的消息类型都满足)。 */
export interface SpeedTrackableMessage {
id: number;
content?: string;
streaming?: boolean;
}
/** 两字符 ≈ 一 token —— 与主聊天 / 站内其它处保持一致的粗估。 */
const CHARS_PER_TOKEN = 2;
/** 流式期间的采样节流。 */
const SAMPLE_THROTTLE_MS = 60;
type Sample = { at: number; chars: number };
type TimingEntry = {
firstSeenAt: number;
endedAt: number | null;
samples: Sample[];
lastChars: number;
};
function contentLength(dialogue: SpeedTrackableMessage): number {
return (dialogue.content ?? "").length;
}
/**
* 返回按 dialogue id 索引的实时度量快照:流式中实时刷新,结束后冻结。
*/
export function useDialogueSpeedMetrics(
dialogues: SpeedTrackableMessage[],
): Map<number, MessageMetric> {
const timingRef = useRef<Map<number, TimingEntry>>(new Map());
const lastSampleAtRef = useRef(0);
// 采样推入时强制重算 —— 与主聊天同理:timing map 是 ref,直接改不会触发渲染。
const [, setTick] = useState(0);
useEffect(() => {
const now = performance.now();
for (const dialogue of dialogues) {
const streaming = dialogue.streaming === true;
let entry = timingRef.current.get(dialogue.id);
// 仅当气泡 **正在流式** 时才开始跟踪 —— 这是「真模型生成」的信号;
// 已完成才出现(系统播报 / 插话 / 澄清回复)的气泡不建项,也就不出胶囊。
if (!entry) {
if (!streaming) continue;
const chars = contentLength(dialogue);
entry = {
firstSeenAt: now,
endedAt: null,
samples: [{ at: now, chars }],
lastChars: chars,
};
timingRef.current.set(dialogue.id, entry);
continue;
}
const chars = contentLength(dialogue);
if (streaming) {
const grew = chars !== entry.lastChars;
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
entry.samples.push({ at: now, chars });
entry.lastChars = chars;
if (entry.samples.length > 256) {
entry.samples.splice(0, entry.samples.length - 256);
}
lastSampleAtRef.current = now;
} else if (grew) {
entry.lastChars = chars;
}
} else if (entry.endedAt === null) {
// 流式结束 → 冻结时长,并以最终内容长度收口。
entry.endedAt = now;
entry.lastChars = chars;
}
}
setTick((v) => v + 1);
}, [dialogues]);
// 仍有气泡在流式时慢速 tick,让实时时长 / 速率平滑刷新。
const anyStreaming = dialogues.some((d) => d.streaming === true);
useEffect(() => {
if (!anyStreaming) return;
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
return () => window.clearInterval(interval);
}, [anyStreaming]);
const out = new Map<number, MessageMetric>();
const now = performance.now();
for (const dialogue of dialogues) {
const entry = timingRef.current.get(dialogue.id);
if (!entry) continue;
const isActive = entry.endedAt === null && dialogue.streaming === true;
const endedAt = entry.endedAt ?? now;
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
let tokensPerSec = 0;
if (isActive && entry.samples.length >= 2) {
// 滑动窗口:取最近 ~1.5s 的样本,反映当前速度而非全程平均。
const samples = entry.samples;
let start = samples.findIndex((s) => s.at >= now - 1500);
if (start === -1) start = samples.length - 2;
if (start < 0) start = 0;
const head = samples[start];
const tail = samples[samples.length - 1];
if (head && tail) {
const elapsedMs = tail.at - head.at;
const chars = Math.max(0, tail.chars - head.chars);
if (elapsedMs > 0) {
tokensPerSec = ((chars / elapsedMs) * 1000) / CHARS_PER_TOKEN;
}
}
} else if (durationMs > 0) {
// 已完成:按全程平均。
tokensPerSec = ((entry.lastChars / durationMs) * 1000) / CHARS_PER_TOKEN;
}
out.set(dialogue.id, {
tokensPerSec,
durationMs,
isActive,
// 圆桌气泡无 usage_metadata,速率永远是字符估算。
fromUsage: false,
// 圆桌每条气泡就是一位席位的一次发言,逐条都展示(无需「turn-final」收敛)。
isTurnFinal: true,
});
}
return out;
}