diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index 0bbbe94fdc..5e84b9175e 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -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 + }: React.ComponentProps<"a">) => ( + + {children} + + ), +}; 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?```$/; @@ -375,6 +395,7 @@ const AUDIO_PLAYER_RE = //; const MarkdownTextImpl = () => { const { text, status } = useMessagePartText(); + const processedText = useMemo(() => preprocessLaTeX(text), [text]); const audioMatch = text.match(AUDIO_PLAYER_RE); if (audioMatch) { @@ -387,6 +408,7 @@ const MarkdownTextImpl = () => { mode="streaming" isAnimating={status.type === "running"} plugins={{ code, math, mermaid }} + components={STREAMDOWN_COMPONENTS} controls={{ code: false, mermaid: { @@ -399,7 +421,7 @@ const MarkdownTextImpl = () => { shikiTheme={["github-light", "github-dark"]} BlockComponent={StreamdownBlock} > - {text} + {processedText} ); diff --git a/studio/frontend/src/components/assistant-ui/sources.tsx b/studio/frontend/src/components/assistant-ui/sources.tsx index 18ee03cad0..da97ff66b5 100644 --- a/studio/frontend/src/components/assistant-ui/sources.tsx +++ b/studio/frontend/src/components/assistant-ui/sources.tsx @@ -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 = { 3: "size-3", 4: "size-4", 5: "size-5" }; + const sizeClass = SIZE_CLASSES[size] ?? "size-3"; if (hasError) { return ( diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 91cba5b02b..e5528f7ee0 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -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"; @@ -90,7 +91,6 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({ - !thread.isEmpty}> {!hideComposer && } @@ -541,6 +541,17 @@ const MessageError: FC = () => { ); }; +const GeneratingIndicator: FC = () => { + const show = useAuiState( + ({ message }) => + message.content.length === 0 && message.status?.type === "running", + ); + if (!show) return null; + return ( + Generating... + ); +}; + const AssistantMessage: FC = () => { return ( { data-role="assistant" >
+ -
{children}
+
{children}
); } @@ -226,7 +226,7 @@ function ToolFallbackArgs({ return (
@@ -251,7 +251,7 @@ function ToolFallbackResult({
     

@@ -316,7 +316,7 @@ const ToolFallbackImpl: ToolCallMessagePartComponent = ({ return ( diff --git a/studio/frontend/src/components/assistant-ui/tool-group.tsx b/studio/frontend/src/components/assistant-ui/tool-group.tsx index f29adb510f..bf7a6a9a25 100644 --- a/studio/frontend/src/components/assistant-ui/tool-group.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-group.tsx @@ -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< @@ -76,7 +76,7 @@ function ToolGroupRoot({ {label} @@ -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} diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx index 28468a10ad..a510ed0d9e 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx @@ -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, @@ -28,6 +28,15 @@ function truncate(text: string): string { function CopyBtn({ text }: { text: string }) { const [copied, setCopied] = useState(false); const timer = useRef | null>(null); + + useEffect(() => { + return () => { + if (timer.current) { + clearTimeout(timer.current); + } + }; + }, []); + const copy = useCallback(() => { if (copyToClipboard(text)) { setCopied(true); @@ -98,14 +107,14 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({ icon={CodeIcon} /> -

+
{/* Code + copy */} {code && (
)} - + {code && } {/* Output */} {isRunning ? ( diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx index 1b65b3c081..f233f951d3 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx @@ -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, @@ -25,6 +25,15 @@ function truncate(text: string): string { function CopyBtn({ text }: { text: string }) { const [copied, setCopied] = useState(false); const timer = useRef | null>(null); + + useEffect(() => { + return () => { + if (timer.current) { + clearTimeout(timer.current); + } + }; + }, []); + const copy = useCallback(() => { if (copyToClipboard(text)) { setCopied(true); @@ -74,7 +83,7 @@ const TerminalToolUIImpl: ToolCallMessagePartComponent = ({ icon={TerminalIcon} /> -
+
{isRunning ? (
diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx index 0635a83ec4..d3a86846de 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx @@ -81,29 +81,27 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({ /> {isRunning ? ( -
+
Searching for “{query}”…
) : sources.length > 0 ? ( -
- {sources.map((source) => ( +
+ {sources.map((source, i) => ( - - - {source.title} - + + {source.title} ))}
) : result ? ( -
+
               {typeof result === "string"
                 ? result
diff --git a/studio/frontend/src/lib/latex.ts b/studio/frontend/src/lib/latex.ts
new file mode 100644
index 0000000000..954d0f7bc6
--- /dev/null
+++ b/studio/frontend/src/lib/latex.ts
@@ -0,0 +1,97 @@
+// Adapted from LibreChat's latex.ts
+// https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts
+//
+// Escapes currency dollar signs so they are not misinterpreted as LaTeX math
+// delimiters when singleDollarTextMath is enabled.
+
+/**
+ * Matches a single $ followed by a number pattern (currency), e.g.:
+ *   $5, $1,000, $5.99, $100K, $3.5M
+ *
+ * Does NOT match:
+ *   $$ (display math), \$ (already escaped), $\alpha (LaTeX command)
+ */
+const CURRENCY_REGEX =
+  /(? {
+  const regions: Array<[number, number]> = [];
+
+  // Fenced code blocks: ```...```
+  const fencedRe = /```[\s\S]*?```/g;
+  let match: RegExpExecArray | null;
+  while ((match = fencedRe.exec(content)) !== null) {
+    regions.push([match.index, match.index + match[0].length]);
+  }
+
+  // Inline code: `...` (but not inside fenced blocks -- we filter below)
+  const inlineRe = /`[^`\n]+`/g;
+  while ((match = inlineRe.exec(content)) !== null) {
+    const start = match.index;
+    const end = start + match[0].length;
+    // Skip if this backtick span falls inside a fenced block
+    let inside = false;
+    for (const [rs, re] of regions) {
+      if (start >= rs && end <= re) {
+        inside = true;
+        break;
+      }
+    }
+    if (!inside) {
+      regions.push([start, end]);
+    }
+  }
+
+  // Sort by start position for binary search
+  regions.sort((a, b) => a[0] - b[0]);
+  return regions;
+}
+
+/**
+ * Binary search to check if a position falls inside any code region.
+ */
+function isInCodeBlock(
+  position: number,
+  regions: Array<[number, number]>,
+): boolean {
+  let lo = 0;
+  let hi = regions.length - 1;
+  while (lo <= hi) {
+    const mid = (lo + hi) >>> 1;
+    const [start, end] = regions[mid];
+    if (position < start) {
+      hi = mid - 1;
+    } else if (position >= end) {
+      lo = mid + 1;
+    } else {
+      return true;
+    }
+  }
+  return false;
+}
+
+/**
+ * Preprocess a markdown string to escape currency dollar signs so they are not
+ * parsed as LaTeX math delimiters.
+ *
+ * - `$5` alone becomes `\$5` (currency, not math)
+ * - `$\alpha$` is untouched (real LaTeX)
+ * - `$$E = mc^2$$` is untouched (display math)
+ * - Currency inside code blocks/spans is untouched
+ */
+export function preprocessLaTeX(content: string): string {
+  if (!content.includes("$")) return content;
+
+  const codeRegions = findCodeBlockRegions(content);
+
+  return content.replace(CURRENCY_REGEX, (match, offset) => {
+    if (isInCodeBlock(offset, codeRegions)) {
+      return match;
+    }
+    return "\\" + match;
+  });
+}