圆桌会商各步接入流式回答速度指示器,复用主聊天 SpeedPill 与全局开关。
新增 DialogueSpeedMeter 与 useDialogueSpeedMetrics,在 Step1/2/3 气泡底部展示 tok/s 与耗时。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user