Skip to content
Merged
28 changes: 25 additions & 3 deletions studio/frontend/src/components/assistant-ui/markdown-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,39 @@
"use client";

import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { preprocessLaTeX } from "@/lib/latex";
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
import { Copy02Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Block, type BlockProps, Streamdown } from "streamdown";
import "katex/dist/katex.min.css";
import { AudioPlayer } from "./audio-player";

const math = createMathPlugin({ singleDollarTextMath: true });
const { withSmoothContextProvider } = INTERNAL;

const STREAMDOWN_COMPONENTS = {
a: ({
href,
children,
...props
Comment on lines +23 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve Streamdown link safety checks

Defining a custom a renderer here replaces Streamdown’s built-in link component, which is where linkSafety confirmation/on-check logic is enforced; as a result, untrusted assistant-generated URLs now open directly in a new tab on click instead of going through the safety gate. This is a security regression for any chat output containing external links and should keep the default safety behavior while adding styling/target attributes.

Useful? React with 👍 / 👎.

}: React.ComponentProps<"a">) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 decoration-primary/40 hover:decoration-primary transition-colors"
{...props}
>
{children}
</a>
),
};
const COPY_RESET_MS = 2000;
const MERMAID_SOURCE_RE = /```mermaid\s*([\s\S]*?)```/i;
const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
Expand Down Expand Up @@ -375,6 +395,7 @@ const AUDIO_PLAYER_RE = /<audio-player\s+src="([^"]+)"\s*\/>/;

const MarkdownTextImpl = () => {
const { text, status } = useMessagePartText();
const processedText = useMemo(() => preprocessLaTeX(text), [text]);

const audioMatch = text.match(AUDIO_PLAYER_RE);
if (audioMatch) {
Expand All @@ -387,6 +408,7 @@ const MarkdownTextImpl = () => {
mode="streaming"
isAnimating={status.type === "running"}
plugins={{ code, math, mermaid }}
components={STREAMDOWN_COMPONENTS}
controls={{
code: false,
mermaid: {
Expand All @@ -399,7 +421,7 @@ const MarkdownTextImpl = () => {
shikiTheme={["github-light", "github-dark"]}
BlockComponent={StreamdownBlock}
>
{text}
{processedText}
</Streamdown>
</div>
);
Expand Down
3 changes: 2 additions & 1 deletion studio/frontend/src/components/assistant-ui/sources.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ function SourceIcon({
}: ComponentProps<"span"> & { url: string; size?: number }) {
const [hasError, setHasError] = useState(false);
const domain = extractDomain(url);
const sizeClass = `size-${size}`;
const SIZE_CLASSES: Record<number, string> = { 3: "size-3", 4: "size-4", 5: "size-5" };
const sizeClass = SIZE_CLASSES[size] ?? "size-3";

if (hasError) {
return (
Expand Down
14 changes: 13 additions & 1 deletion studio/frontend/src/components/assistant-ui/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
import { Button } from "@/components/ui/button";
import { sentAudioNames } from "@/features/chat/api/chat-adapter";
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
Expand Down Expand Up @@ -90,7 +91,6 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({

<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mt-auto flex w-full flex-col gap-4 overflow-visible bg-background pb-4 md:pb-4">
<ThreadScrollToBottom />
<GeneratingSpinner />
<AuiIf condition={({ thread }) => !thread.isEmpty}>
{!hideComposer && <ComposerAnimated />}
</AuiIf>
Expand Down Expand Up @@ -541,13 +541,25 @@ const MessageError: FC = () => {
);
};

const GeneratingIndicator: FC = () => {
const show = useAuiState(
({ message }) =>
message.content.length === 0 && message.status?.type === "running",
);
if (!show) return null;
return (
<AnimatedShinyText className="text-sm">Generating...</AnimatedShinyText>
);
};

const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
data-role="assistant"
>
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
<GeneratingIndicator />
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
Expand Down
14 changes: 7 additions & 7 deletions studio/frontend/src/components/assistant-ui/tool-fallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function ToolFallbackRoot({
open={isOpen}
onOpenChange={handleOpenChange}
className={cn(
"aui-tool-fallback-root group/tool-fallback-root w-full corner-squircle rounded-lg border py-3",
"aui-tool-fallback-root group/tool-fallback-root w-full py-1",
className,
)}
style={
Expand Down Expand Up @@ -124,7 +124,7 @@ function ToolFallbackTrigger({
<CollapsibleTrigger
data-slot="tool-fallback-trigger"
className={cn(
"aui-tool-fallback-trigger group/trigger flex w-full items-center gap-2 px-4 text-sm transition-colors",
"aui-tool-fallback-trigger group/trigger flex w-full items-center gap-2 py-1.5 text-sm transition-colors",
className,
)}
{...props}
Expand Down Expand Up @@ -207,7 +207,7 @@ function ToolFallbackContent({
)}
{...props}
>
<div className="mt-3 flex flex-col gap-2 border-t pt-2">{children}</div>
<div className="mt-1 flex flex-col gap-2 pl-5">{children}</div>
</CollapsibleContent>
);
}
Expand All @@ -226,7 +226,7 @@ function ToolFallbackArgs({
return (
<div
data-slot="tool-fallback-args"
className={cn("aui-tool-fallback-args px-4", className)}
className={cn("aui-tool-fallback-args", className)}
{...props}
>
<pre className="aui-tool-fallback-args-value whitespace-pre-wrap">
Expand All @@ -251,7 +251,7 @@ function ToolFallbackResult({
<div
data-slot="tool-fallback-result"
className={cn(
"aui-tool-fallback-result border-t border-dashed px-4 pt-2",
"aui-tool-fallback-result pt-2",
className,
)}
{...props}
Expand Down Expand Up @@ -292,7 +292,7 @@ function ToolFallbackError({
return (
<div
data-slot="tool-fallback-error"
className={cn("aui-tool-fallback-error px-4", className)}
className={cn("aui-tool-fallback-error", className)}
{...props}
>
<p className="aui-tool-fallback-error-header font-semibold text-muted-foreground">
Expand All @@ -316,7 +316,7 @@ const ToolFallbackImpl: ToolCallMessagePartComponent = ({

return (
<ToolFallbackRoot
className={cn(isCancelled && "border-muted-foreground/30 bg-muted/30")}
className={cn(isCancelled && "bg-muted/30")}
>
<ToolFallbackTrigger toolName={toolName} status={status} />
<ToolFallbackContent>
Expand Down
18 changes: 9 additions & 9 deletions studio/frontend/src/components/assistant-ui/tool-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", {
variants: {
variant: {
outline: "corner-squircle rounded-lg border py-3",
ghost: "",
ghost: "rounded-lg bg-muted/10 py-2",
muted: "corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
},
},
defaultVariants: { variant: "outline" },
defaultVariants: { variant: "ghost" },
});

export type ToolGroupRootProps = Omit<
Expand Down Expand Up @@ -76,7 +76,7 @@ function ToolGroupRoot({
<Collapsible
ref={collapsibleRef}
data-slot="tool-group-root"
data-variant={variant ?? "outline"}
data-variant={variant ?? "ghost"}
open={isOpen}
onOpenChange={handleOpenChange}
className={cn(
Expand Down Expand Up @@ -111,9 +111,10 @@ function ToolGroupTrigger({
<CollapsibleTrigger
data-slot="tool-group-trigger"
className={cn(
"aui-tool-group-trigger group/trigger flex items-center gap-2 text-sm transition-colors",
"group-data-[variant=outline]/tool-group-root:w-full group-data-[variant=outline]/tool-group-root:px-4",
"group-data-[variant=muted]/tool-group-root:w-full group-data-[variant=muted]/tool-group-root:px-4",
"aui-tool-group-trigger group/trigger flex w-full items-center gap-2 text-sm transition-colors",
"group-data-[variant=outline]/tool-group-root:px-4",
"group-data-[variant=muted]/tool-group-root:px-4",
"group-data-[variant=ghost]/tool-group-root:px-0",
className,
)}
{...props}
Expand All @@ -134,9 +135,7 @@ function ToolGroupTrigger({
<span
data-slot="tool-group-trigger-label"
className={cn(
"aui-tool-group-trigger-label-wrapper relative inline-block text-left font-medium leading-none",
"group-data-[variant=outline]/tool-group-root:grow",
"group-data-[variant=muted]/tool-group-root:grow",
"aui-tool-group-trigger-label-wrapper relative inline-block grow text-left font-medium leading-none",
)}
>
<span>{label}</span>
Expand Down Expand Up @@ -189,6 +188,7 @@ function ToolGroupContent({
"mt-2 flex flex-col gap-2",
"group-data-[variant=outline]/tool-group-root:mt-3 group-data-[variant=outline]/tool-group-root:border-t group-data-[variant=outline]/tool-group-root:px-4 group-data-[variant=outline]/tool-group-root:pt-3",
"group-data-[variant=muted]/tool-group-root:mt-3 group-data-[variant=muted]/tool-group-root:border-t group-data-[variant=muted]/tool-group-root:px-4 group-data-[variant=muted]/tool-group-root:pt-3",
"group-data-[variant=ghost]/tool-group-root:mt-1 group-data-[variant=ghost]/tool-group-root:gap-1",
)}
>
{children}
Expand Down
15 changes: 12 additions & 3 deletions studio/frontend/src/components/assistant-ui/tool-ui-python.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { copyToClipboard } from "@/lib/copy-to-clipboard";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { code as codePlugin } from "@streamdown/code";
import { CheckIcon, CodeIcon, CopyIcon, LoaderIcon } from "lucide-react";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import {
ToolFallbackContent,
Expand All @@ -28,6 +28,15 @@ function truncate(text: string): string {
function CopyBtn({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
return () => {
if (timer.current) {
clearTimeout(timer.current);
}
};
}, []);
Comment on lines +32 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The addition of useEffect to clear the setTimeout timer on component unmount is a good practice. This prevents potential memory leaks and ensures that timers do not attempt to update state on unmounted components, which can lead to unexpected behavior.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you for the compliment


const copy = useCallback(() => {
if (copyToClipboard(text)) {
setCopied(true);
Expand Down Expand Up @@ -98,14 +107,14 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({
icon={CodeIcon}
/>
<ToolFallbackContent>
<div className="flex flex-col px-4">
<div className="border-l-2 border-muted-foreground/20 pl-2">
{/* Code + copy */}
{code && (
<div className="flex justify-end">
<CopyBtn text={code} />
</div>
)}
<HighlightedCode code={code} language="python" />
{code && <HighlightedCode code={code} language="python" />}

{/* Output */}
{isRunning ? (
Expand Down
13 changes: 11 additions & 2 deletions studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, CopyIcon, LoaderIcon, TerminalIcon } from "lucide-react";
import { memo, useCallback, useRef, useState } from "react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import {
ToolFallbackContent,
ToolFallbackRoot,
Expand All @@ -25,6 +25,15 @@ function truncate(text: string): string {
function CopyBtn({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
return () => {
if (timer.current) {
clearTimeout(timer.current);
}
};
}, []);
Comment on lines +29 to +35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the change in tool-ui-python.tsx, adding useEffect to clear the setTimeout timer on unmount is a crucial improvement. This prevents potential memory leaks and ensures proper resource management.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol


const copy = useCallback(() => {
if (copyToClipboard(text)) {
setCopied(true);
Expand Down Expand Up @@ -74,7 +83,7 @@ const TerminalToolUIImpl: ToolCallMessagePartComponent = ({
icon={TerminalIcon}
/>
<ToolFallbackContent>
<div className="flex flex-col px-4">
<div className="border-l-2 border-muted-foreground/20 pl-2">
{isRunning ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
Expand Down
20 changes: 9 additions & 11 deletions studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,29 +81,27 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
/>
<ToolFallbackContent>
{isRunning ? (
<div className="flex items-center gap-2 px-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span>Searching for &ldquo;{query}&rdquo;&hellip;</span>
</div>
) : sources.length > 0 ? (
<div className="flex flex-col gap-1.5 px-4">
{sources.map((source) => (
<div className="flex flex-wrap gap-1.5">
{sources.map((source, i) => (
<Source
key={source.url}
key={`${source.url}-${i}`}
href={source.url}
variant="outline"
size="default"
className="flex w-full max-w-full items-center gap-2 py-1.5"
size="sm"
className="inline-flex items-center gap-1.5"
>
<SourceIcon url={source.url} className="size-3.5" />
<SourceTitle className="max-w-none flex-1 truncate">
{source.title}
</SourceTitle>
<SourceIcon url={source.url} size={3} />
<SourceTitle>{source.title}</SourceTitle>
</Source>
))}
</div>
) : result ? (
<div className="px-4">
<div>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
{typeof result === "string"
? result
Expand Down
Loading