- {timing.firstTokenTime !== undefined && (
-
- First token
-
- {formatTimingMs(timing.firstTokenTime)}
-
-
+
+ {st ? (
+ <>
+ {/* Server-side metrics (GGUF) */}
+ {st?.prompt_ms != null && (
+
+ Prompt eval
+
+ {formatTimingMs(st.prompt_ms)}
+
+
+ )}
+ {(st?.prompt_n ?? 0) > 1 && st?.prompt_per_second != null && (
+
+ Prompt speed
+
+ {st.prompt_per_second.toFixed(1)} tok/s
+
+
+ )}
+ {st?.predicted_ms != null && (
+
+ Generation
+
+ {formatTimingMs(st.predicted_ms)}
+
+
+ )}
+ {st?.predicted_per_second != null && (
+
+ Speed
+
+ {st.predicted_per_second.toFixed(1)} tok/s
+
+
+ )}
+ {timing.tokenCount !== undefined && (
+
+ Tokens
+
+ {formatNumber(timing.tokenCount)}
+
+
+ )}
+ {(st?.cache_n ?? 0) > 0 && (
+
+ Cache hits
+
+ {formatNumber(st!.cache_n)}
+
+
+ )}
+
+
+ Total
+
+ {formatTimingMs(timing.totalStreamTime)}
+
+
+
+ Chunks
+
+ {timing.totalChunks}
+
+
+ >
+ ) : (
+ <>
+ {/* Client-side metrics (safetensors fallback) */}
+ {timing.firstTokenTime !== undefined && (
+
+ First token
+
+ {formatTimingMs(timing.firstTokenTime)}
+
+
+ )}
+
+ Total
+
+ {formatTimingMs(timing.totalStreamTime)}
+
+
+
+ Chunks
+
+ {timing.totalChunks}
+
+
+ >
)}
-
- Total
-
- {formatTimingMs(timing.totalStreamTime)}
-
-
-
- Chunks
- {timing.totalChunks}
-
diff --git a/studio/frontend/src/components/assistant-ui/sources.tsx b/studio/frontend/src/components/assistant-ui/sources.tsx
index ada503fd54..18ee03cad0 100644
--- a/studio/frontend/src/components/assistant-ui/sources.tsx
+++ b/studio/frontend/src/components/assistant-ui/sources.tsx
@@ -1,9 +1,24 @@
"use client";
-import { memo, useState, type ComponentProps } from "react";
-import type { SourceMessagePartComponent } from "@assistant-ui/react";
+import {
+ memo,
+ useState,
+ useRef,
+ useEffect,
+ useCallback,
+ type ComponentProps,
+ type FC,
+} from "react";
+import { useMessage } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { Badge, badgeVariants, type BadgeProps } from "./badge";
+import {
+ HoverCard,
+ HoverCardTrigger,
+ HoverCardContent,
+} from "@/components/ui/hover-card";
+
+// ── Helpers ──────────────────────────────────────────────────
const extractDomain = (url: string): string => {
try {
@@ -18,20 +33,24 @@ const getDomainInitial = (url: string): string => {
return domain.charAt(0).toUpperCase();
};
+// ── Sub-components ───────────────────────────────────────────
+
function SourceIcon({
url,
className,
+ size = 3,
...props
-}: ComponentProps<"span"> & { url: string }) {
+}: ComponentProps<"span"> & { url: string; size?: number }) {
const [hasError, setHasError] = useState(false);
const domain = extractDomain(url);
+ const sizeClass = `size-${size}`;
if (hasError) {
return (
setHasError(true)}
{...(props as ComponentProps<"img">)}
/>
@@ -97,39 +116,200 @@ function Source({
);
}
-const SourcesImpl: SourceMessagePartComponent = ({
- url,
- title,
- sourceType,
-}) => {
- if (sourceType !== "url" || !url) return null;
+// ── Source badge with hover card ─────────────────────────────
- const domain = extractDomain(url);
- const displayTitle = title || domain;
+interface SourceData {
+ url: string;
+ title: string;
+ description?: string;
+}
+
+const SourceBadge: FC<{ source: SourceData }> = ({ source }) => {
+ const domain = extractDomain(source.url);
+ const displayTitle = source.title || domain;
return (
-
-
-
- {displayTitle}
-
-
+
+
+
+
+
+ {displayTitle}
+
+
+
+
+
+
+
+
+ {source.title || domain}
+
+
{domain}
+ {source.description && (
+
+ {source.description}
+
+ )}
+
+
+
+
);
};
-const Sources = memo(SourcesImpl) as unknown as SourceMessagePartComponent & {
+// ── Grouped sources with 2-row collapse ─────────────────────
+
+const SourcesGroup: FC = () => {
+ const message = useMessage();
+ const containerRef = useRef(null);
+ const [visibleCount, setVisibleCount] = useState(null);
+ const [expanded, setExpanded] = useState(false);
+
+ // Extract source parts from the message
+ const sources: SourceData[] = [];
+ if (message.content) {
+ for (const part of message.content) {
+ if (
+ part.type === "source" &&
+ "sourceType" in part &&
+ part.sourceType === "url" &&
+ "url" in part &&
+ part.url
+ ) {
+ sources.push({
+ url: part.url as string,
+ title: (part as { title?: string }).title || "",
+ description: (part as { metadata?: { description?: string } })
+ .metadata?.description,
+ });
+ }
+ }
+ }
+
+ // Measure how many badges fit in 2 rows
+ const measure = useCallback(() => {
+ const container = containerRef.current;
+ if (!container || sources.length === 0) return;
+
+ const children = Array.from(container.children) as HTMLElement[];
+ if (children.length === 0) return;
+
+ // Find the top of the first child as baseline
+ const firstTop = children[0].offsetTop;
+ let rowCount = 1;
+ let prevTop = firstTop;
+ let cutoff = children.length;
+
+ for (let i = 1; i < children.length; i++) {
+ const childTop = children[i].offsetTop;
+ if (childTop > prevTop) {
+ rowCount++;
+ prevTop = childTop;
+ if (rowCount > 2) {
+ cutoff = i;
+ break;
+ }
+ }
+ }
+
+ setVisibleCount(rowCount > 2 ? cutoff : null);
+ }, [sources.length]);
+
+ useEffect(() => {
+ measure();
+ }, [measure]);
+
+ // Re-measure on resize
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ const observer = new ResizeObserver(measure);
+ observer.observe(container);
+ return () => observer.disconnect();
+ }, [measure]);
+
+ if (sources.length === 0) return null;
+
+ const shouldCollapse = visibleCount !== null && visibleCount < sources.length;
+ const displayedSources =
+ expanded || !shouldCollapse ? sources : sources.slice(0, visibleCount);
+ const hiddenCount = sources.length - (visibleCount ?? sources.length);
+
+ return (
+
+ {/* Hidden measurement container — renders all badges to measure row positions */}
+
+ {sources.map((source) => (
+
+
+
+ {source.title || extractDomain(source.url)}
+
+
+ ))}
+
+
+ {/* Visible container */}
+
+ {displayedSources.map((source) => (
+
+ ))}
+ {shouldCollapse && !expanded && (
+
+ )}
+ {shouldCollapse && expanded && (
+
+ )}
+
+
+ );
+};
+
+// ── Individual source (renders null — SourcesGroup handles all) ──
+
+const SourcesNoop: FC> = () => null;
+
+// ── Exports ──────────────────────────────────────────────────
+
+const Sources = memo(SourcesNoop) as unknown as FC> & {
Root: typeof Source;
Icon: typeof SourceIcon;
Title: typeof SourceTitle;
+ Group: typeof SourcesGroup;
};
Sources.displayName = "Sources";
Sources.Root = Source;
Sources.Icon = SourceIcon;
Sources.Title = SourceTitle;
+Sources.Group = SourcesGroup;
export {
Sources,
+ SourcesGroup,
Source,
SourceIcon,
SourceTitle,
diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx
index 567c25b53f..4d472ba208 100644
--- a/studio/frontend/src/components/assistant-ui/thread.tsx
+++ b/studio/frontend/src/components/assistant-ui/thread.tsx
@@ -9,7 +9,7 @@ import {
import { MessageTiming } from "@/components/assistant-ui/message-timing";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
-import { Sources } from "@/components/assistant-ui/sources";
+import { Sources, SourcesGroup } from "@/components/assistant-ui/sources";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { ToolGroup } from "@/components/assistant-ui/tool-group";
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
@@ -114,26 +114,68 @@ const ThreadScrollToBottom: FC = () => {
);
};
+const SUGGESTION_TOOLS: Record> = {
+ "How do you fine-tune an audio model with Unsloth?": ["thinking", "search"],
+ "Show me a live weather dashboard, no API key needed": ["thinking", "code", "search"],
+ "Solve the integral of x·sin(x), and verify it step by step": ["thinking", "code"],
+ "Draw an SVG of a cute sloth": ["thinking", "code", "search"],
+};
+
+const toolIconMap = {
+ thinking: { icon: LightbulbIcon, label: "Thinking" },
+ search: { icon: GlobeIcon, label: "Web search" },
+ code: { icon: TerminalIcon, label: "Code" },
+} as const;
+
const SuggestionItem: FC = () => {
const aui = useAui();
const prompt = useAuiState(({ suggestion }) => suggestion.prompt);
const isDisabled = useAuiState(({ thread }) => thread.isDisabled);
const isRunning = useAuiState(({ thread }) => thread.isRunning);
+ const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
+ const supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning);
+ const allTools = SUGGESTION_TOOLS[prompt] ?? [];
+ const tools = allTools.filter((tool) => {
+ if (tool === "thinking") return supportsReasoning;
+ return supportsTools;
+ });
return (
);
};
@@ -517,6 +559,7 @@ const AssistantMessage: FC = () => {
},
}}
/>
+
diff --git a/studio/frontend/src/components/assistant-ui/tool-group.tsx b/studio/frontend/src/components/assistant-ui/tool-group.tsx
index d1ad01335e..f29adb510f 100644
--- a/studio/frontend/src/components/assistant-ui/tool-group.tsx
+++ b/studio/frontend/src/components/assistant-ui/tool-group.tsx
@@ -9,6 +9,8 @@ import {
type PropsWithChildren,
} from "react";
import { ChevronDownIcon, LoaderIcon } from "lucide-react";
+import { Wrench01Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
import { cva, type VariantProps } from "class-variance-authority";
import { useScrollLock } from "@assistant-ui/react";
import {
@@ -116,11 +118,18 @@ function ToolGroupTrigger({
)}
{...props}
>
- {active && (
+ {active ? (