圆桌会商各步接入流式回答速度指示器,复用主聊天 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: "缓慢" };
|
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 < 1000) return `${Math.round(ms)} ms`;
|
||||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
||||||
const mins = Math.floor(ms / 60_000);
|
const mins = Math.floor(ms / 60_000);
|
||||||
@@ -31,7 +31,7 @@ function formatDuration(ms: number): string {
|
|||||||
return `${mins}m ${secs}s`;
|
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 fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
|
||||||
const { color, label } = classifySpeed(metric.tokensPerSec);
|
const { color, label } = classifySpeed(metric.tokensPerSec);
|
||||||
const display =
|
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 { 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 { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
|
||||||
|
import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpeedMeter";
|
||||||
import {
|
import {
|
||||||
clarificationKey,
|
clarificationKey,
|
||||||
IntentClarificationCard,
|
IntentClarificationCard,
|
||||||
@@ -51,6 +54,7 @@ import {
|
|||||||
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
|
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
|
||||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||||
|
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||||
import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
|
import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
|
||||||
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
|
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||||
import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants";
|
import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants";
|
||||||
@@ -108,6 +112,11 @@ export function Step1Panel(props: Step1PanelProps) {
|
|||||||
const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } =
|
const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } =
|
||||||
useAutoScroll(step1ScrollRef, [interactiveMessages]);
|
useAutoScroll(step1ScrollRef, [interactiveMessages]);
|
||||||
|
|
||||||
|
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
|
||||||
|
const [localSettings] = useLocalSettings();
|
||||||
|
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
|
||||||
|
const messageMetrics = useDialogueSpeedMetrics(interactiveMessages);
|
||||||
|
|
||||||
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
|
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
|
||||||
const [inputText, setInputText] = useState<string>("");
|
const [inputText, setInputText] = useState<string>("");
|
||||||
// 待发送的本地附件(点发送时连同文本一起上传)。
|
// 待发送的本地附件(点发送时连同文本一起上传)。
|
||||||
@@ -165,6 +174,7 @@ export function Step1Panel(props: Step1PanelProps) {
|
|||||||
<Step1Message
|
<Step1Message
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
msg={msg}
|
msg={msg}
|
||||||
|
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
|
||||||
intentReady={intentReady}
|
intentReady={intentReady}
|
||||||
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
|
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
|
||||||
intentClarificationSelections={intentClarificationSelections}
|
intentClarificationSelections={intentClarificationSelections}
|
||||||
@@ -338,6 +348,11 @@ export function Step1Panel(props: Step1PanelProps) {
|
|||||||
*/
|
*/
|
||||||
interface Step1MessageProps {
|
interface Step1MessageProps {
|
||||||
msg: InteractiveMessage;
|
msg: InteractiveMessage;
|
||||||
|
/**
|
||||||
|
* 本条气泡的「回答速度」度量(tok/s + 时长)。仅对流式过的智能体发言有值;
|
||||||
|
* 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。
|
||||||
|
*/
|
||||||
|
metric?: MessageMetric;
|
||||||
intentReady: object | null;
|
intentReady: object | null;
|
||||||
pendingIntentClarificationMsgId: number | null;
|
pendingIntentClarificationMsgId: number | null;
|
||||||
intentClarificationSelections: Map<string, IntentClarificationSelection>;
|
intentClarificationSelections: Map<string, IntentClarificationSelection>;
|
||||||
@@ -354,6 +369,7 @@ interface Step1MessageProps {
|
|||||||
|
|
||||||
function Step1Message({
|
function Step1Message({
|
||||||
msg,
|
msg,
|
||||||
|
metric,
|
||||||
intentReady,
|
intentReady,
|
||||||
pendingIntentClarificationMsgId,
|
pendingIntentClarificationMsgId,
|
||||||
intentClarificationSelections,
|
intentClarificationSelections,
|
||||||
@@ -520,6 +536,8 @@ function Step1Message({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{metric && <DialogueSpeedMeter metric={metric} />}
|
||||||
</MessageBubble>
|
</MessageBubble>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ import {
|
|||||||
/** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */
|
/** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */
|
||||||
const SHOW_BACKGROUND_SUSPEND = true;
|
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 { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
|
||||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
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 { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
|
||||||
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
|
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
|
||||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||||
|
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||||
import type {
|
import type {
|
||||||
AgentConfig,
|
AgentConfig,
|
||||||
ParallelSeatView,
|
ParallelSeatView,
|
||||||
@@ -189,6 +194,12 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
|
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
|
||||||
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
|
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
|
||||||
|
|
||||||
|
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
|
||||||
|
// 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。
|
||||||
|
const [localSettings] = useLocalSettings();
|
||||||
|
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
|
||||||
|
const dialogueMetrics = useDialogueSpeedMetrics(step2RoundtableDialogues);
|
||||||
|
|
||||||
// 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换,
|
// 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换,
|
||||||
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
|
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
|
||||||
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
|
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
|
||||||
@@ -456,6 +467,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
{msg ? (
|
{msg ? (
|
||||||
<Step2Message
|
<Step2Message
|
||||||
msg={msg}
|
msg={msg}
|
||||||
|
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
|
||||||
resolveAgentName={resolveAgentName}
|
resolveAgentName={resolveAgentName}
|
||||||
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
||||||
onFileCardClick={(path) => {
|
onFileCardClick={(path) => {
|
||||||
@@ -476,6 +488,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
<Step2Message
|
<Step2Message
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
msg={msg}
|
msg={msg}
|
||||||
|
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
|
||||||
resolveAgentName={resolveAgentName}
|
resolveAgentName={resolveAgentName}
|
||||||
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
||||||
onFileCardClick={(path) => {
|
onFileCardClick={(path) => {
|
||||||
@@ -687,6 +700,11 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
*/
|
*/
|
||||||
export interface Step2MessageProps {
|
export interface Step2MessageProps {
|
||||||
msg: Step2Dialogue;
|
msg: Step2Dialogue;
|
||||||
|
/**
|
||||||
|
* 本条气泡的「回答速度」度量(tok/s + 时长)。仅对流式过的模型发言有值;
|
||||||
|
* 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。
|
||||||
|
*/
|
||||||
|
metric?: MessageMetric;
|
||||||
/** 把派活 chip 的 agent id 解析成中文席位名。 */
|
/** 把派活 chip 的 agent id 解析成中文席位名。 */
|
||||||
resolveAgentName?: (idOrName: string) => string;
|
resolveAgentName?: (idOrName: string) => string;
|
||||||
onArtifactStepClick?: (
|
onArtifactStepClick?: (
|
||||||
@@ -698,6 +716,7 @@ export interface Step2MessageProps {
|
|||||||
|
|
||||||
export function Step2Message({
|
export function Step2Message({
|
||||||
msg,
|
msg,
|
||||||
|
metric,
|
||||||
resolveAgentName,
|
resolveAgentName,
|
||||||
onArtifactStepClick,
|
onArtifactStepClick,
|
||||||
onFileCardClick,
|
onFileCardClick,
|
||||||
@@ -749,6 +768,8 @@ export function Step2Message({
|
|||||||
disabled={!msg.agentThreadId}
|
disabled={!msg.agentThreadId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{metric && <DialogueSpeedMeter metric={metric} />}
|
||||||
</MessageBubble>
|
</MessageBubble>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||||
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
buildArtifactDownloadUrl,
|
buildArtifactDownloadUrl,
|
||||||
@@ -45,6 +46,7 @@ import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2Sandbo
|
|||||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||||
|
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||||
import {
|
import {
|
||||||
useStep3Report,
|
useStep3Report,
|
||||||
type ReportIntent,
|
type ReportIntent,
|
||||||
@@ -480,6 +482,10 @@ function AgentProgressPanel({
|
|||||||
const inputLocked = phase === "creating";
|
const inputLocked = phase === "creating";
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
|
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
|
||||||
|
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
|
||||||
|
const [localSettings] = useLocalSettings();
|
||||||
|
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
|
||||||
|
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
|
||||||
const [revisionText, setRevisionText] = useState("");
|
const [revisionText, setRevisionText] = useState("");
|
||||||
|
|
||||||
const handleRevisionSubmit = (e: FormEvent<HTMLFormElement>) => {
|
const handleRevisionSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||||
@@ -548,6 +554,7 @@ function AgentProgressPanel({
|
|||||||
<Step2Message
|
<Step2Message
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
msg={msg}
|
msg={msg}
|
||||||
|
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
|
||||||
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
|
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
|
||||||
onFileCardClick={(path) => {
|
onFileCardClick={(path) => {
|
||||||
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
|
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||||
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
buildArtifactDownloadUrl,
|
buildArtifactDownloadUrl,
|
||||||
@@ -54,6 +55,7 @@ import { markdownComponents } from "@/roundtable-planning/components/markdown-co
|
|||||||
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
||||||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||||
|
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||||
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
|
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
|
||||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||||
|
|
||||||
@@ -573,6 +575,10 @@ function AgentProgressPanel({
|
|||||||
}) {
|
}) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
|
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
|
||||||
|
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
|
||||||
|
const [localSettings] = useLocalSettings();
|
||||||
|
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
|
||||||
|
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
|
||||||
const [askText, setAskText] = useState("");
|
const [askText, setAskText] = useState("");
|
||||||
// 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。
|
// 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。
|
||||||
const [askFiles, setAskFiles] = useState<File[]>([]);
|
const [askFiles, setAskFiles] = useState<File[]>([]);
|
||||||
@@ -655,6 +661,7 @@ function AgentProgressPanel({
|
|||||||
<Step2Message
|
<Step2Message
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
msg={msg}
|
msg={msg}
|
||||||
|
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
|
||||||
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
|
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
|
||||||
onFileCardClick={(path) => {
|
onFileCardClick={(path) => {
|
||||||
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, 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