diff --git a/frontend-web/src/components/workspace/messages/message-token-usage.tsx b/frontend-web/src/components/workspace/messages/message-token-usage.tsx
index 16019b2c..f45b2ef0 100644
--- a/frontend-web/src/components/workspace/messages/message-token-usage.tsx
+++ b/frontend-web/src/components/workspace/messages/message-token-usage.tsx
@@ -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 =
diff --git a/frontend-web/src/roundtable-planning/components/DialogueSpeedMeter.tsx b/frontend-web/src/roundtable-planning/components/DialogueSpeedMeter.tsx
new file mode 100644
index 00000000..8993a505
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/components/DialogueSpeedMeter.tsx
@@ -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 (
+
+
+
+ {formatDuration(metric.durationMs)}
+
+
+ );
+}
diff --git a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx
index 168b4c23..695bae14 100644
--- a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx
+++ b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx
@@ -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("");
// 待发送的本地附件(点发送时连同文本一起上传)。
@@ -165,6 +174,7 @@ export function Step1Panel(props: Step1PanelProps) {
;
@@ -354,6 +369,7 @@ interface Step1MessageProps {
function Step1Message({
msg,
+ metric,
intentReady,
pendingIntentClarificationMsgId,
intentClarificationSelections,
@@ -520,6 +536,8 @@ function Step1Message({
)}
)}
+
+ {metric && }
);
}
diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx
index 943b1914..37409cb3 100644
--- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx
+++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx
@@ -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 ? (
openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
@@ -476,6 +488,7 @@ export function Step2Panel(props: Step2PanelProps) {
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 && }
);
}
diff --git a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx
index 4c57d8c7..93394a0a 100644
--- a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx
+++ b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx
@@ -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(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) => {
@@ -548,6 +554,7 @@ function AgentProgressPanel({
onArtifactStepClick(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
diff --git a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx
index 6d22d527..1832e725 100644
--- a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx
+++ b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx
@@ -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(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([]);
@@ -655,6 +661,7 @@ function AgentProgressPanel({
onArtifactStepClick(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
diff --git a/frontend-web/src/roundtable-planning/hooks/useDialogueSpeedMetrics.ts b/frontend-web/src/roundtable-planning/hooks/useDialogueSpeedMetrics.ts
new file mode 100644
index 00000000..d2851dfb
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/hooks/useDialogueSpeedMetrics.ts
@@ -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`,复用主聊天的 `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 {
+ const timingRef = useRef