From b58e731c195b31227d263db27c190dc46dedeff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 12 Jun 2026 15:43:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=86=E6=A1=8C=E4=BC=9A=E5=95=86=E5=90=84?= =?UTF-8?q?=E6=AD=A5=E6=8E=A5=E5=85=A5=E6=B5=81=E5=BC=8F=E5=9B=9E=E7=AD=94?= =?UTF-8?q?=E9=80=9F=E5=BA=A6=E6=8C=87=E7=A4=BA=E5=99=A8=EF=BC=8C=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E4=B8=BB=E8=81=8A=E5=A4=A9=20SpeedPill=20=E4=B8=8E?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E5=BC=80=E5=85=B3=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 DialogueSpeedMeter 与 useDialogueSpeedMetrics,在 Step1/2/3 气泡底部展示 tok/s 与耗时。 Co-authored-by: Cursor --- .../messages/message-token-usage.tsx | 4 +- .../components/DialogueSpeedMeter.tsx | 27 ++++ .../components/Step1Panel.tsx | 18 +++ .../components/Step2Panel.tsx | 21 +++ .../components/Step3ReportMode.tsx | 7 + .../components/Step3SummaryMode.tsx | 7 + .../hooks/useDialogueSpeedMetrics.ts | 153 ++++++++++++++++++ 7 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/components/DialogueSpeedMeter.tsx create mode 100644 frontend-web/src/roundtable-planning/hooks/useDialogueSpeedMetrics.ts 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>(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(); + 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; +}