Studio: add assistant response details panel (#6842)
* Studio: add assistant response details panel * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Hide model badge by default, show on hover/focus Wrap MessageResponseModelBadge in a span with hidden/group-hover visibility classes to reduce visual clutter. The badge now only displays when hovering or focusing on the assistant message, improving the UI presentation. Updated corresponding tests to verify the new CSS classes. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
10d8f985a2
commit
ba450b437e
8 changed files with 776 additions and 49 deletions
|
|
@ -0,0 +1,483 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
customProviderDisplayName,
|
||||
parseExternalModelId,
|
||||
useChatPreferencesStore,
|
||||
useChatRuntimeStore,
|
||||
useExternalProvidersStore,
|
||||
} from "@/features/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FileDatabaseIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useMessage, useMessageTiming } from "@assistant-ui/react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
type ResponseDetailsMetadata = {
|
||||
modelId?: string;
|
||||
modelLabel?: string;
|
||||
responseModelId?: string;
|
||||
providerId?: string;
|
||||
providerName?: string;
|
||||
providerType?: string;
|
||||
startedAt?: number;
|
||||
finishedAt?: number;
|
||||
durationMs?: number;
|
||||
sessionId?: string | null;
|
||||
cancelId?: string;
|
||||
toolCalls?: string[];
|
||||
tools?: Record<string, boolean | undefined>;
|
||||
};
|
||||
|
||||
type ContextUsageMetadata = {
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
cachedTokens?: number;
|
||||
cacheWriteTokens?: number;
|
||||
modelId?: string;
|
||||
};
|
||||
|
||||
type MessageCustomMetadata = {
|
||||
responseDetails?: ResponseDetailsMetadata;
|
||||
contextUsage?: ContextUsageMetadata;
|
||||
serverTimings?: Record<string, unknown>;
|
||||
reasoningDuration?: number;
|
||||
};
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function formatNumber(value: number | undefined): string | null {
|
||||
return value == null ? null : value.toLocaleString();
|
||||
}
|
||||
|
||||
function formatMs(value: number | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
if (value < 1000) return `${Math.round(value)}ms`;
|
||||
return `${(value / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function formatRate(value: number | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
return `${value.toFixed(1)} tok/s`;
|
||||
}
|
||||
|
||||
function formatDate(value: Date | number | string | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
const TOOL_CATEGORY_LABELS: Record<string, string> = {
|
||||
search: "Search",
|
||||
fetch: "Fetch",
|
||||
code: "Code",
|
||||
images: "Images",
|
||||
mcp: "MCP",
|
||||
docs: "Docs",
|
||||
artifacts: "Canvas",
|
||||
};
|
||||
|
||||
const TOOL_CALL_LABELS: Record<string, string> = {
|
||||
web_search: "Search",
|
||||
web_fetch: "Fetch",
|
||||
code_execution: "Code",
|
||||
python: "Python",
|
||||
terminal: "Terminal",
|
||||
image_generation: "Images",
|
||||
search_knowledge_base: "Docs",
|
||||
render_html: "Canvas",
|
||||
};
|
||||
|
||||
function uniqueValues(values: string[]): string[] {
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
|
||||
function toolCategoryFromCall(toolName: string): string | null {
|
||||
const normalized = toolName.toLowerCase();
|
||||
if (normalized === "web_search") return "search";
|
||||
if (normalized === "web_fetch") return "fetch";
|
||||
if (
|
||||
normalized === "code_execution" ||
|
||||
normalized === "python" ||
|
||||
normalized === "terminal"
|
||||
) {
|
||||
return "code";
|
||||
}
|
||||
if (normalized === "image_generation") return "images";
|
||||
if (normalized === "search_knowledge_base") return "docs";
|
||||
if (normalized === "render_html") return "artifacts";
|
||||
if (normalized.startsWith("mcp__")) return "mcp";
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatToolCallName(toolName: string): string {
|
||||
const normalized = toolName.toLowerCase();
|
||||
if (TOOL_CALL_LABELS[normalized]) return TOOL_CALL_LABELS[normalized];
|
||||
if (normalized.startsWith("mcp__")) return `MCP: ${toolName.slice(5)}`;
|
||||
return toolName
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function toolCallsFromContent(content: unknown): string[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return uniqueValues(
|
||||
content
|
||||
.map((part) =>
|
||||
part && typeof part === "object" && "type" in part
|
||||
? (part as { type?: unknown; toolName?: unknown })
|
||||
: null,
|
||||
)
|
||||
.filter(
|
||||
(part): part is { type: "tool-call"; toolName: string } =>
|
||||
part?.type === "tool-call" &&
|
||||
typeof part.toolName === "string" &&
|
||||
part.toolName.length > 0,
|
||||
)
|
||||
.map((part) => part.toolName),
|
||||
);
|
||||
}
|
||||
|
||||
function enabledTools(
|
||||
tools: Record<string, boolean | undefined> | undefined,
|
||||
toolCalls: string[],
|
||||
): string | null {
|
||||
if (!tools && toolCalls.length === 0) return null;
|
||||
const activeKeys = new Set<string>();
|
||||
for (const key of Object.keys(TOOL_CATEGORY_LABELS)) {
|
||||
if (tools?.[key] === true) activeKeys.add(key);
|
||||
}
|
||||
for (const toolName of toolCalls) {
|
||||
const key = toolCategoryFromCall(toolName);
|
||||
if (key) activeKeys.add(key);
|
||||
}
|
||||
const active = Object.keys(TOOL_CATEGORY_LABELS)
|
||||
.filter((key) => activeKeys.has(key))
|
||||
.map((key) => TOOL_CATEGORY_LABELS[key]);
|
||||
return active.length > 0 ? active.join(", ") : "None";
|
||||
}
|
||||
|
||||
function calledTools(toolCalls: string[]): string | null {
|
||||
if (toolCalls.length === 0) return null;
|
||||
return uniqueValues(toolCalls.map(formatToolCallName)).join(", ");
|
||||
}
|
||||
|
||||
function DetailSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-md bg-muted/45 p-3">
|
||||
<h3 className="mb-2 font-heading text-foreground text-sm">{title}</h3>
|
||||
<div className="grid gap-2">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode | null | undefined;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
if (value == null || value === "") return null;
|
||||
return (
|
||||
<div className="grid grid-cols-[8.5rem_minmax(0,1fr)] items-start gap-3 text-[13px]">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 break-words text-right text-foreground",
|
||||
mono && "font-mono tabular-nums",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useResponseModelDisplay() {
|
||||
const message = useMessage();
|
||||
const models = useChatRuntimeStore((s) => s.models);
|
||||
const providers = useExternalProvidersStore((s) => s.providers);
|
||||
|
||||
const custom = (
|
||||
message.metadata as Record<string, unknown> | undefined
|
||||
)?.custom as MessageCustomMetadata | undefined;
|
||||
const responseDetails = custom?.responseDetails;
|
||||
const usage = custom?.contextUsage;
|
||||
const serverTimings = custom?.serverTimings;
|
||||
|
||||
const recordedModelId =
|
||||
responseDetails?.responseModelId ??
|
||||
responseDetails?.modelId ??
|
||||
usage?.modelId;
|
||||
const parsedExternal = parseExternalModelId(recordedModelId);
|
||||
const provider = parsedExternal
|
||||
? providers.find((candidate) => candidate.id === parsedExternal.providerId)
|
||||
: null;
|
||||
const modelSummary = models.find(
|
||||
(candidate) => candidate.id === recordedModelId,
|
||||
);
|
||||
const modelLabel =
|
||||
responseDetails?.modelLabel ??
|
||||
responseDetails?.responseModelId ??
|
||||
parsedExternal?.modelId ??
|
||||
modelSummary?.name ??
|
||||
recordedModelId ??
|
||||
"Not recorded";
|
||||
const providerLabel =
|
||||
responseDetails?.providerName ??
|
||||
provider?.name ??
|
||||
(responseDetails?.providerType
|
||||
? customProviderDisplayName(responseDetails.providerType)
|
||||
: parsedExternal
|
||||
? customProviderDisplayName(provider?.providerType)
|
||||
: recordedModelId
|
||||
? "Local model"
|
||||
: null);
|
||||
|
||||
return {
|
||||
message,
|
||||
custom,
|
||||
responseDetails,
|
||||
usage,
|
||||
serverTimings,
|
||||
modelLabel,
|
||||
providerLabel,
|
||||
};
|
||||
}
|
||||
|
||||
export const MessageResponseModelBadge: FC<{ className?: string }> = ({
|
||||
className,
|
||||
}) => {
|
||||
const showResponseModel = useChatPreferencesStore(
|
||||
(state) => state.showResponseModel,
|
||||
);
|
||||
const { modelLabel, providerLabel } = useResponseModelDisplay();
|
||||
|
||||
if (!showResponseModel || modelLabel === "Not recorded") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"aui-response-model-badge inline-flex min-h-5 max-w-full items-center text-muted-foreground/80 text-xs font-medium leading-5 opacity-0 transition-opacity duration-150 group-hover/assistant-message:opacity-100 group-focus-within/assistant-message:opacity-100",
|
||||
className,
|
||||
)}
|
||||
title={providerLabel ? `${modelLabel} - ${providerLabel}` : modelLabel}
|
||||
>
|
||||
<span className="min-w-0 truncate align-middle">{modelLabel}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const MessageResponseDetailsSheet: FC<{
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}> = ({ open, onOpenChange }) => {
|
||||
const timing = useMessageTiming();
|
||||
const {
|
||||
message,
|
||||
responseDetails,
|
||||
usage,
|
||||
serverTimings,
|
||||
modelLabel,
|
||||
providerLabel,
|
||||
} = useResponseModelDisplay();
|
||||
const promptTokens =
|
||||
usage?.promptTokens ?? asNumber(serverTimings?.prompt_n);
|
||||
const completionTokens =
|
||||
usage?.completionTokens ??
|
||||
timing?.tokenCount ??
|
||||
asNumber(serverTimings?.predicted_n);
|
||||
const totalTokens =
|
||||
usage?.totalTokens ??
|
||||
(promptTokens != null && completionTokens != null
|
||||
? promptTokens + completionTokens
|
||||
: undefined);
|
||||
const totalTime =
|
||||
responseDetails?.durationMs ?? timing?.totalStreamTime ?? undefined;
|
||||
const summaryLabel =
|
||||
modelLabel === "Not recorded" ? "Model not recorded" : `Used ${modelLabel}`;
|
||||
const messageToolCalls = toolCallsFromContent(message.content);
|
||||
const toolCalls =
|
||||
responseDetails?.toolCalls && responseDetails.toolCalls.length > 0
|
||||
? responseDetails.toolCalls
|
||||
: messageToolCalls;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(28rem,100vw)] p-0 sm:max-w-[28rem]"
|
||||
>
|
||||
<SheetHeader className="border-b p-4">
|
||||
<SheetTitle className="flex items-center gap-2 pr-10 font-heading text-base">
|
||||
<HugeiconsIcon
|
||||
icon={FileDatabaseIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon text-chat-icon-fg"
|
||||
/>
|
||||
Response details
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Timing, model, token, and tool details for this response.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
|
||||
<div className="min-w-0 rounded-md border border-border/70 bg-card p-3">
|
||||
<p className="min-w-0 break-words font-heading text-foreground text-sm">
|
||||
{summaryLabel}
|
||||
</p>
|
||||
{providerLabel ? (
|
||||
<p className="mt-1 min-w-0 break-words text-muted-foreground text-xs">
|
||||
{providerLabel}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DetailSection title="Response">
|
||||
<DetailRow label="Model" value={modelLabel} />
|
||||
<DetailRow
|
||||
label="Requested"
|
||||
value={
|
||||
responseDetails?.modelId &&
|
||||
responseDetails.modelId !== responseDetails.responseModelId
|
||||
? responseDetails.modelId
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<DetailRow label="Provider" value={providerLabel} />
|
||||
<DetailRow label="Message ID" value={message.id} mono={true} />
|
||||
<DetailRow label="Created" value={formatDate(message.createdAt)} />
|
||||
<DetailRow
|
||||
label="Started"
|
||||
value={formatDate(responseDetails?.startedAt)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Finished"
|
||||
value={formatDate(responseDetails?.finishedAt)}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Tokens">
|
||||
<DetailRow label="Prompt" value={formatNumber(promptTokens)} mono />
|
||||
<DetailRow
|
||||
label="Output"
|
||||
value={formatNumber(completionTokens)}
|
||||
mono
|
||||
/>
|
||||
<DetailRow label="Total" value={formatNumber(totalTokens)} mono />
|
||||
<DetailRow
|
||||
label="Cache hits"
|
||||
value={formatNumber(
|
||||
usage?.cachedTokens ?? asNumber(serverTimings?.cache_n),
|
||||
)}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Cache writes"
|
||||
value={formatNumber(usage?.cacheWriteTokens)}
|
||||
mono
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Timing">
|
||||
<DetailRow label="Total" value={formatMs(totalTime)} mono />
|
||||
<DetailRow
|
||||
label="First token"
|
||||
value={formatMs(timing?.firstTokenTime)}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Prompt eval"
|
||||
value={formatMs(asNumber(serverTimings?.prompt_ms))}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Generation"
|
||||
value={formatMs(asNumber(serverTimings?.predicted_ms))}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Speed"
|
||||
value={formatRate(
|
||||
asNumber(serverTimings?.predicted_per_second) ??
|
||||
timing?.tokensPerSecond,
|
||||
)}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Chunks"
|
||||
value={formatNumber(timing?.totalChunks)}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Tool calls"
|
||||
value={formatNumber(timing?.toolCallCount)}
|
||||
mono
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Tools">
|
||||
<DetailRow
|
||||
label="Enabled"
|
||||
value={enabledTools(responseDetails?.tools, toolCalls)}
|
||||
/>
|
||||
<DetailRow label="Called" value={calledTools(toolCalls)} />
|
||||
<DetailRow
|
||||
label="Confirmation"
|
||||
value={
|
||||
responseDetails?.tools?.confirmToolCalls === true
|
||||
? "On"
|
||||
: responseDetails?.tools?.confirmToolCalls === false
|
||||
? "Off"
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Bypass"
|
||||
value={
|
||||
responseDetails?.tools?.bypassPermissions === true
|
||||
? "On"
|
||||
: responseDetails?.tools?.bypassPermissions === false
|
||||
? "Off"
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<DetailRow label="Session" value={responseDetails?.sessionId} mono />
|
||||
<DetailRow label="Run ID" value={responseDetails?.cancelId} mono />
|
||||
</DetailSection>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { MessageResponseModelBadge } from "@/components/assistant-ui/message-response-details-sheet";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
|
|
@ -390,14 +391,17 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
|
|||
onOpenChange={handleOpenChange}
|
||||
variant={variant}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ReasoningTrigger
|
||||
className="min-w-0 flex-1"
|
||||
className="min-w-0 flex-none"
|
||||
active={isReasoningStreaming}
|
||||
// Prefer server timing when available.
|
||||
duration={persistedDuration || duration}
|
||||
/>
|
||||
<div className="flex w-16 shrink-0 justify-end">
|
||||
<span className="hidden min-w-0 max-w-[12rem] group-hover/assistant-message:inline-flex group-focus-within/assistant-message:inline-flex sm:max-w-[16rem]">
|
||||
<MessageResponseModelBadge className="min-w-0" />
|
||||
</span>
|
||||
<div className="ml-auto flex w-16 shrink-0 justify-end">
|
||||
{isOpen && !isReasoningStreaming && (
|
||||
<ReasoningCopyButton startIndex={startIndex} endIndex={endIndex} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import {
|
|||
import { downloadImagePart } from "@/components/assistant-ui/image";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { MessageHtmlArtifacts } from "@/components/assistant-ui/message-html-artifacts";
|
||||
import {
|
||||
MessageResponseDetailsSheet,
|
||||
MessageResponseModelBadge,
|
||||
} from "@/components/assistant-ui/message-response-details-sheet";
|
||||
import { MessageTiming } from "@/components/assistant-ui/message-timing";
|
||||
import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
|
||||
import { RagSourcesGroup } from "@/components/assistant-ui/rag-sources";
|
||||
|
|
@ -3564,6 +3568,9 @@ const AssistantMessage: FC = () => {
|
|||
const aui = useAui();
|
||||
const messageId = useAuiState(({ message }) => message.id);
|
||||
const messageContent = useAuiState(({ message }) => message.content);
|
||||
const hasReasoningParts = useAuiState(({ message }) =>
|
||||
message.parts.some((part) => part.type === "reasoning"),
|
||||
);
|
||||
const incognito = useChatRuntimeStore((s) => s.incognito);
|
||||
|
||||
// Use global store for editing state to ensure a single source of truth
|
||||
|
|
@ -3620,7 +3627,7 @@ const AssistantMessage: FC = () => {
|
|||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-assistant-message-root relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) pt-0.5 pb-4 text-[15.5px] [font-weight:410] tracking-[0.01em] dark:tracking-[0.02em]"
|
||||
className="group/assistant-message aui-assistant-message-root relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) pt-0.5 pb-4 text-[15.5px] [font-weight:410] tracking-[0.01em] dark:tracking-[0.02em]"
|
||||
data-role="assistant"
|
||||
>
|
||||
<div className="aui-assistant-message-content wrap-break-word min-w-0 text-[#0d0d0d] dark:text-foreground leading-relaxed">
|
||||
|
|
@ -3649,6 +3656,11 @@ const AssistantMessage: FC = () => {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!hasReasoningParts ? (
|
||||
<div className="pointer-events-none relative h-0 min-w-0">
|
||||
<MessageResponseModelBadge className="absolute -top-6 left-0 max-w-[min(22rem,100%)]" />
|
||||
</div>
|
||||
) : null}
|
||||
<GeneratingIndicator />
|
||||
<CancelledIndicator />
|
||||
<DiffusionCanvas />
|
||||
|
|
@ -3893,58 +3905,76 @@ const EditAssistantMessageButton: FC = () => {
|
|||
|
||||
const AssistantActionBar: FC = () => {
|
||||
const { forkMessage, forkDisabled } = useForkMessageAction();
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning={true}
|
||||
className="aui-assistant-action-bar-root col-start-3 row-start-2 flex items-center gap-1 text-chat-icon-fg [&_button:not([data-slot=message-timing-trigger])]:size-8 [&_button]:!rounded-full [&_button:hover]:bg-chat-icon-bg-hover [&_button:hover]:text-chat-icon-fg-hover"
|
||||
>
|
||||
<CopyButton />
|
||||
<EditAssistantMessageButton />
|
||||
<ActionBarPrimitive.Reload asChild={true}>
|
||||
<TooltipIconButton tooltip="Refresh">
|
||||
<RefreshCwIcon strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
<ForkCountBadge />
|
||||
<DeleteMessageButton />
|
||||
<ActionBarMorePrimitive.Root>
|
||||
<ActionBarMorePrimitive.Trigger asChild={true}>
|
||||
<TooltipIconButton
|
||||
tooltip="More"
|
||||
className="data-[state=open]:bg-accent"
|
||||
>
|
||||
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
|
||||
<>
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning={true}
|
||||
className="aui-assistant-action-bar-root col-start-3 row-start-2 flex items-center gap-1 text-chat-icon-fg [&_button:not([data-slot=message-timing-trigger])]:size-8 [&_button]:!rounded-full [&_button:hover]:bg-chat-icon-bg-hover [&_button:hover]:text-chat-icon-fg-hover"
|
||||
>
|
||||
<CopyButton />
|
||||
<EditAssistantMessageButton />
|
||||
<ActionBarPrimitive.Reload asChild={true}>
|
||||
<TooltipIconButton tooltip="Refresh">
|
||||
<RefreshCwIcon strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarMorePrimitive.Trigger>
|
||||
<ActionBarMorePrimitive.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-[21px] bg-popover px-[9px] py-2 text-popover-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:shadow-none"
|
||||
>
|
||||
<ActionBarMorePrimitive.Item
|
||||
disabled={forkDisabled}
|
||||
onSelect={() => void forkMessage()}
|
||||
className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-[12px] px-3 py-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
</ActionBarPrimitive.Reload>
|
||||
<ForkCountBadge />
|
||||
<DeleteMessageButton />
|
||||
<ActionBarMorePrimitive.Root>
|
||||
<ActionBarMorePrimitive.Trigger asChild={true}>
|
||||
<TooltipIconButton
|
||||
tooltip="More"
|
||||
className="data-[state=open]:bg-accent"
|
||||
>
|
||||
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarMorePrimitive.Trigger>
|
||||
<ActionBarMorePrimitive.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-[21px] bg-popover px-[9px] py-2 text-popover-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:shadow-none"
|
||||
>
|
||||
<GitBranchIcon strokeWidth={1.75} className="size-icon" />
|
||||
Fork in new chat
|
||||
</ActionBarMorePrimitive.Item>
|
||||
<ActionBarPrimitive.ExportMarkdown asChild={true}>
|
||||
<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-[12px] px-3 py-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
|
||||
<ActionBarMorePrimitive.Item
|
||||
disabled={forkDisabled}
|
||||
onSelect={() => void forkMessage()}
|
||||
className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-[12px] px-3 py-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<GitBranchIcon strokeWidth={1.75} className="size-icon" />
|
||||
Fork in new chat
|
||||
</ActionBarMorePrimitive.Item>
|
||||
<ActionBarPrimitive.ExportMarkdown asChild={true}>
|
||||
<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-[12px] px-3 py-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
|
||||
<HugeiconsIcon
|
||||
icon={Download01Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
Export as Markdown
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.ExportMarkdown>
|
||||
<ActionBarMorePrimitive.Item
|
||||
onSelect={() => setDetailsOpen(true)}
|
||||
className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-[12px] px-3 py-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Download01Icon}
|
||||
icon={FileDatabaseIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
Export as Markdown
|
||||
See response details
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.ExportMarkdown>
|
||||
</ActionBarMorePrimitive.Content>
|
||||
</ActionBarMorePrimitive.Root>
|
||||
<MessageTiming side="top" className="h-8 px-2" />
|
||||
</ActionBarPrimitive.Root>
|
||||
</ActionBarMorePrimitive.Content>
|
||||
</ActionBarMorePrimitive.Root>
|
||||
<MessageTiming side="top" className="h-8 px-2" />
|
||||
</ActionBarPrimitive.Root>
|
||||
<MessageResponseDetailsSheet
|
||||
open={detailsOpen}
|
||||
onOpenChange={setDetailsOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -140,6 +140,32 @@ interface ServerTimings {
|
|||
diffusion_steps_per_second?: number;
|
||||
}
|
||||
|
||||
interface ResponseDetailsMetadata {
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
responseModelId: string;
|
||||
providerId?: string;
|
||||
providerName: string;
|
||||
providerType: string;
|
||||
startedAt: number;
|
||||
finishedAt: number;
|
||||
durationMs: number;
|
||||
sessionId?: string;
|
||||
cancelId: string;
|
||||
toolCalls: string[];
|
||||
tools: {
|
||||
search: boolean;
|
||||
fetch: boolean;
|
||||
code: boolean;
|
||||
images: boolean;
|
||||
mcp: boolean;
|
||||
docs: boolean;
|
||||
artifacts: boolean;
|
||||
confirmToolCalls: boolean;
|
||||
bypassPermissions: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type RunMessages = Parameters<ChatModelAdapter["run"]>[0]["messages"];
|
||||
type RunMessage = RunMessages[number];
|
||||
|
||||
|
|
@ -1769,6 +1795,9 @@ export function createOpenAIStreamAdapter(
|
|||
(provider) => provider.id === externalSelection.providerId,
|
||||
)
|
||||
: null;
|
||||
const selectedModelSummary = runtime.models.find(
|
||||
(model) => model.id === params.checkpoint,
|
||||
);
|
||||
const externalApiKey = externalProvider
|
||||
? getExternalProviderApiKey(externalProvider.id).trim()
|
||||
: "";
|
||||
|
|
@ -2151,6 +2180,7 @@ export function createOpenAIStreamAdapter(
|
|||
let waitingFirstChunk = true;
|
||||
let firstTokenSettled = false;
|
||||
const streamStartTime = Date.now();
|
||||
let responseModelId = externalSelection?.modelId ?? params.checkpoint;
|
||||
let firstTokenTime: number | undefined;
|
||||
let totalChunks = 0;
|
||||
let resolveFirstToken: (() => void) | null = null;
|
||||
|
|
@ -2372,6 +2402,59 @@ export function createOpenAIStreamAdapter(
|
|||
const externalBackendProviderType = toExternalBackendProviderType(
|
||||
externalProvider?.providerType,
|
||||
);
|
||||
const buildResponseDetails = (
|
||||
finishedAt: number,
|
||||
): ResponseDetailsMetadata => ({
|
||||
modelId: params.checkpoint,
|
||||
modelLabel:
|
||||
(isExternalRequest || responseModelId !== params.checkpoint
|
||||
? responseModelId
|
||||
: selectedModelSummary?.name || responseModelId) ||
|
||||
params.checkpoint ||
|
||||
"Unknown model",
|
||||
responseModelId:
|
||||
responseModelId ||
|
||||
externalSelection?.modelId ||
|
||||
params.checkpoint,
|
||||
...(externalProvider?.id ? { providerId: externalProvider.id } : {}),
|
||||
providerName:
|
||||
externalProvider?.name ??
|
||||
(isExternalRequest ? "External provider" : "Local model"),
|
||||
providerType: externalProvider?.providerType ?? "local",
|
||||
startedAt: streamStartTime,
|
||||
finishedAt,
|
||||
durationMs: finishedAt - streamStartTime,
|
||||
...(sandboxSessionId ? { sessionId: sandboxSessionId } : {}),
|
||||
cancelId,
|
||||
toolCalls: Array.from(
|
||||
new Set(
|
||||
toolCallParts
|
||||
.map((part) => part.toolName)
|
||||
.filter(
|
||||
(toolName): toolName is string =>
|
||||
typeof toolName === "string" && toolName.length > 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
tools: {
|
||||
search:
|
||||
webSearchEnabledForThisTurn ||
|
||||
(!isExternalRequest && supportsTools && toolsEnabled),
|
||||
fetch: webFetchEnabledForThisTurn,
|
||||
code:
|
||||
codeExecEnabledForThisTurn ||
|
||||
(!isExternalRequest && supportsTools && codeToolsEnabled),
|
||||
images: imageGenerationEnabledForThisTurn,
|
||||
mcp: !isExternalRequest && supportsTools && mcpEnabledForChat,
|
||||
docs:
|
||||
!isExternalRequest &&
|
||||
supportsTools &&
|
||||
(ragEnabled || projectRagEnabled),
|
||||
artifacts: renderHtmlToolEnabledForThisTurn,
|
||||
confirmToolCalls,
|
||||
bypassPermissions,
|
||||
},
|
||||
});
|
||||
const externalCapabilities = getProviderCapabilities(
|
||||
externalProvider?.providerType,
|
||||
);
|
||||
|
|
@ -2768,6 +2851,11 @@ export function createOpenAIStreamAdapter(
|
|||
const stream = streamChatCompletions(requestPayload, abortSignal);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const chunkModel = (chunk as { model?: unknown }).model;
|
||||
if (typeof chunkModel === "string" && chunkModel.length > 0) {
|
||||
responseModelId = chunkModel;
|
||||
}
|
||||
|
||||
// Handle tool status events
|
||||
const toolStatusText = (
|
||||
chunk as unknown as { _toolStatus?: string }
|
||||
|
|
@ -3435,11 +3523,12 @@ export function createOpenAIStreamAdapter(
|
|||
});
|
||||
}
|
||||
|
||||
const finishedAt = Date.now();
|
||||
const finalTiming = buildTiming(
|
||||
streamStartTime,
|
||||
totalChunks,
|
||||
serverPromptEvalTime ?? firstTokenTime,
|
||||
Date.now() - streamStartTime,
|
||||
finishedAt - streamStartTime,
|
||||
finalTokenCount,
|
||||
toolCallParts.length,
|
||||
finalTokPerSec,
|
||||
|
|
@ -3475,6 +3564,7 @@ export function createOpenAIStreamAdapter(
|
|||
modelId: params.checkpoint,
|
||||
}
|
||||
: undefined,
|
||||
responseDetails: buildResponseDetails(finishedAt),
|
||||
timing: finalTiming,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,7 +26,12 @@ export {
|
|||
type PlusMenuItemId,
|
||||
} from "./stores/plus-menu-prefs-store";
|
||||
export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
|
||||
export { isExternalModelId } from "./external-providers";
|
||||
export {
|
||||
customProviderDisplayName,
|
||||
isExternalModelId,
|
||||
parseExternalModelId,
|
||||
} from "./external-providers";
|
||||
export { useExternalProvidersStore } from "./stores/external-providers-store";
|
||||
export { ChatSearchDialog } from "./components/chat-search-dialog";
|
||||
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
|
||||
export type { ProjectRecord } from "./types";
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@ import { persist } from "zustand/middleware";
|
|||
// Client-side chat UI prefs kept in localStorage, not the chat DB.
|
||||
// confirmDeleteChats: when off, deleting a chat skips the confirm dialog.
|
||||
// showModelDisclaimer: when off, hide the "LLMs can make mistakes" footer note.
|
||||
// showResponseModel: when on, assistant responses show the producing model.
|
||||
export interface ChatPreferencesState {
|
||||
confirmDeleteChats: boolean;
|
||||
setConfirmDeleteChats: (value: boolean) => void;
|
||||
showModelDisclaimer: boolean;
|
||||
setShowModelDisclaimer: (value: boolean) => void;
|
||||
showResponseModel: boolean;
|
||||
setShowResponseModel: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
||||
|
|
@ -23,6 +26,9 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
|||
showModelDisclaimer: true,
|
||||
setShowModelDisclaimer: (showModelDisclaimer) =>
|
||||
set({ showModelDisclaimer }),
|
||||
showResponseModel: false,
|
||||
setShowResponseModel: (showResponseModel) =>
|
||||
set({ showResponseModel }),
|
||||
}),
|
||||
{
|
||||
name: "unsloth_chat_preferences",
|
||||
|
|
@ -32,6 +38,7 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
|||
...current,
|
||||
confirmDeleteChats: saved?.confirmDeleteChats ?? true,
|
||||
showModelDisclaimer: saved?.showModelDisclaimer ?? true,
|
||||
showResponseModel: saved?.showResponseModel ?? false,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -213,6 +213,12 @@ export function ChatTab() {
|
|||
const setShowModelDisclaimer = useChatPreferencesStore(
|
||||
(state) => state.setShowModelDisclaimer,
|
||||
);
|
||||
const showResponseModel = useChatPreferencesStore(
|
||||
(state) => state.showResponseModel,
|
||||
);
|
||||
const setShowResponseModel = useChatPreferencesStore(
|
||||
(state) => state.setShowResponseModel,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void countAllChats().then(setCount);
|
||||
|
|
@ -412,6 +418,15 @@ export function ChatTab() {
|
|||
onCheckedChange={setShowModelDisclaimer}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label="Show response model"
|
||||
description="Show model metadata in assistant responses."
|
||||
>
|
||||
<Switch
|
||||
checked={showResponseModel}
|
||||
onCheckedChange={setShowResponseModel}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.chat.artifacts.title")}>
|
||||
|
|
|
|||
93
tests/studio/test_chat_response_details_ui_contract.py
Normal file
93
tests/studio/test_chat_response_details_ui_contract.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Static contract for the chat response-details action and metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
THREAD_TSX = REPO / "studio/frontend/src/components/assistant-ui/thread.tsx"
|
||||
DETAILS_TSX = (
|
||||
REPO / "studio/frontend/src/components/assistant-ui/message-response-details-sheet.tsx"
|
||||
)
|
||||
REASONING_TSX = REPO / "studio/frontend/src/components/assistant-ui/reasoning.tsx"
|
||||
ADAPTER_TS = REPO / "studio/frontend/src/features/chat/api/chat-adapter.ts"
|
||||
CHAT_PREFS_TS = REPO / "studio/frontend/src/features/chat/stores/chat-preferences-store.ts"
|
||||
CHAT_TAB_TSX = REPO / "studio/frontend/src/features/settings/tabs/chat-tab.tsx"
|
||||
|
||||
|
||||
def test_assistant_more_menu_exposes_response_details_action():
|
||||
src = THREAD_TSX.read_text()
|
||||
assert "MessageResponseDetailsSheet" in src
|
||||
assert "See response details" in src
|
||||
assert "setDetailsOpen(true)" in src
|
||||
|
||||
|
||||
def test_response_details_sheet_uses_unsloth_sheet_and_key_sections():
|
||||
src = DETAILS_TSX.read_text()
|
||||
assert "SheetContent" in src
|
||||
assert "Response details" in src
|
||||
assert "MessageResponseModelBadge" in src
|
||||
assert "showResponseModel" in src
|
||||
assert "ChipIcon" not in src
|
||||
assert "s.params.checkpoint" not in src
|
||||
assert "Not recorded" in src
|
||||
assert "min-w-0 break-words font-heading" in src
|
||||
assert "toolCallsFromContent(message.content)" in src
|
||||
assert 'label="Called"' in src
|
||||
for section in ["Response", "Tokens", "Timing", "Tools"]:
|
||||
assert f'title="{section}"' in src
|
||||
for field in ["Model", "Provider", "Total", "Cache hits", "Enabled", "Called"]:
|
||||
assert f'label="{field}"' in src
|
||||
|
||||
|
||||
def test_response_model_chip_is_user_configurable_and_rendered_in_metadata_rows():
|
||||
prefs_src = CHAT_PREFS_TS.read_text()
|
||||
chat_tab_src = CHAT_TAB_TSX.read_text()
|
||||
thread_src = THREAD_TSX.read_text()
|
||||
reasoning_src = REASONING_TSX.read_text()
|
||||
|
||||
assert "showResponseModel: boolean" in prefs_src
|
||||
assert "showResponseModel: false" in prefs_src
|
||||
assert "showResponseModel: saved?.showResponseModel ?? false" in prefs_src
|
||||
assert "Show response model" in chat_tab_src
|
||||
assert "setShowResponseModel" in chat_tab_src
|
||||
assert "aui-response-model-badge inline-flex min-h-5" in DETAILS_TSX.read_text()
|
||||
assert "leading-5" in DETAILS_TSX.read_text()
|
||||
assert "group-hover/assistant-message:opacity-100" in DETAILS_TSX.read_text()
|
||||
assert "MessageResponseModelBadge" in thread_src
|
||||
assert "hasReasoningParts" in thread_src
|
||||
assert "group/assistant-message aui-assistant-message-root" in thread_src
|
||||
assert "pointer-events-none relative h-0" in thread_src
|
||||
assert "MessageResponseModelBadge" in reasoning_src
|
||||
assert 'className="min-w-0 flex-none"' in reasoning_src
|
||||
assert "hidden min-w-0 max-w-[12rem]" in reasoning_src
|
||||
assert "group-hover/assistant-message:inline-flex" in reasoning_src
|
||||
|
||||
|
||||
def test_response_details_metadata_is_persisted_without_backend_schema_change():
|
||||
src = ADAPTER_TS.read_text()
|
||||
assert "interface ResponseDetailsMetadata" in src
|
||||
assert "buildResponseDetails" in src
|
||||
assert "responseDetails: buildResponseDetails(finishedAt)" in src
|
||||
assert "toolCalls: Array.from(" in src
|
||||
assert "!isExternalRequest && supportsTools && toolsEnabled" in src
|
||||
assert "!isExternalRequest && supportsTools && codeToolsEnabled" in src
|
||||
assert re.search(r"selectedModelSummary\?\.name\s*\|\|\s*responseModelId", src)
|
||||
assert "providerName" in src
|
||||
assert "cancelId" in src
|
||||
metadata_block = src[
|
||||
src.find("interface ResponseDetailsMetadata") : src.find("type RunMessages")
|
||||
]
|
||||
builder_block = src[
|
||||
src.find("const buildResponseDetails") : src.find("const externalCapabilities")
|
||||
]
|
||||
for forbidden in [
|
||||
"encrypted_api_key",
|
||||
"externalApiKey",
|
||||
"apiKey",
|
||||
"providerKey",
|
||||
"secret",
|
||||
]:
|
||||
assert forbidden not in metadata_block
|
||||
assert forbidden not in builder_block
|
||||
Loading…
Add table
Add a link
Reference in a new issue