diff --git a/src/components/assistant-ui/assistant-loader.tsx b/src/components/assistant-ui/assistant-loader.tsx index 0ae83746..48736fce 100644 --- a/src/components/assistant-ui/assistant-loader.tsx +++ b/src/components/assistant-ui/assistant-loader.tsx @@ -1,33 +1,27 @@ "use client"; -import { useAuiState } from "@assistant-ui/react"; -import { DotLottieReact } from "@lottiefiles/dotlottie-react"; +import { lazy, Suspense } from "react"; import { useTheme } from "next-themes"; -export const AssistantLoader = () => { - const { resolvedTheme } = useTheme(); - const isRunning = useAuiState( - ({ message }) => (message as { status?: { type: string } })?.status?.type === "running" - ); - - const isMessageEmpty = useAuiState(({ message }) => { - const msg = message as any; - return !msg?.content || (Array.isArray(msg.content) && msg.content.length === 0); - }); - - if (!isRunning || !isMessageEmpty) return null; +const DotLottieReact = lazy(() => + import("@lottiefiles/dotlottie-react").then((m) => ({ default: m.DotLottieReact })) +); +export const AssistantLoaderVisual = () => { + const { resolvedTheme } = useTheme(); const lottieSrc = resolvedTheme === 'light' ? '/thinkexlight.lottie' : '/logo.lottie'; return (
- + }> + + Thinking...
); diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx index ba0518b9..ca4fce8b 100644 --- a/src/components/assistant-ui/markdown-text.tsx +++ b/src/components/assistant-ui/markdown-text.tsx @@ -5,13 +5,16 @@ import "streamdown/styles.css"; import { createCodePlugin } from "@streamdown/code"; import { mermaid } from "@streamdown/mermaid"; import { createMathPlugin } from "@streamdown/math"; -import { useMessagePartText, useAuiState, type TextMessagePartProps } from "@assistant-ui/react"; +import { useMessagePartText, type TextMessagePartProps } from "@assistant-ui/react"; import { Children, + createContext, isValidElement, memo, + useContext, useRef, useEffect, + useMemo, type ReactNode, } from "react"; import type { @@ -30,13 +33,23 @@ import { getCitationUrl } from "@/lib/utils/preprocess-latex"; import { resolveItemByPath } from "@/lib/ai/tools/workspace-search-utils"; import { preprocessLatex } from "@/lib/utils/preprocess-latex"; import { cn } from "@/lib/utils"; +import type { Item } from "@/lib/workspace-state/types"; const math = createMathPlugin({ singleDollarTextMath: true }); const code = createCodePlugin({ themes: ["one-dark-pro", "one-dark-pro"], }) as CodeHighlighterPlugin; -/** Parse page number from citation ref (e.g. "Title | quote | p. 5" or "Title | p. 5"). */ +type CitationContextValue = { + workspaceState: Item[]; + navigateToItem: ReturnType; + openWorkspaceItem: (id: string) => void; + setCitationHighlightQuery: (query: { itemId: string; query: string; pageNumber?: number }) => void; + citationUrls: Map; +}; + +const CitationContext = createContext(null); + function parseCitationPage(ref: string): { title: string; quote?: string; pageNumber?: number } { const segments = ref.split(/\s*\|\s*/).map((s) => s.trim()).filter(Boolean); if (segments.length === 0) return { title: "" }; @@ -56,7 +69,6 @@ function parseCitationPage(ref: string): { title: string; quote?: string; pageNu return { title, quote: quote || undefined, pageNumber }; } -/** Extract raw text from citation element children (handles nested elements). */ function extractCitationText(children: ReactNode): string { if (typeof children === "string") return children.trim(); if (children == null) return ""; @@ -74,43 +86,34 @@ function extractCitationText(children: ReactNode): string { .trim(); } -/** - * SurfSense-style citation renderer. - * Content is the ref itself: urlciteN (URL), or Title, or Title|quote (workspace). - */ const CitationRenderer = memo( ({ children }: { children?: ReactNode }) => { + const ctx = useContext(CitationContext); const ref = extractCitationText(children); if (!ref) return null; - // URL placeholder (from preprocess): render as direct link - const url = getCitationUrl(ref); + const url = ctx ? getCitationUrl(ref, ctx.citationUrls) : undefined; if (url) { return ; } - // Direct URL (in case preprocess didn't run, e.g. edge case) if (ref.startsWith("http://") || ref.startsWith("https://")) { return ; } - // Workspace citation: Title, Title|quote, or Title|quote|p. 5 (with optional page) + if (!ctx) return null; + + const { workspaceState, navigateToItem, openWorkspaceItem, setCitationHighlightQuery } = ctx; + const parsed = parseCitationPage(ref); const { title: parsedTitle, quote: parsedQuote, pageNumber } = parsed; const title = parsedTitle; const quote = parsedQuote; - const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); - const navigateToItem = useNavigateToItem(); - const openWorkspaceItem = useUIStore((s) => s.openWorkspaceItem); - // Normalize for matching: trim, lowercase, strip .pdf (model may include it; items often don't) const titleNorm = (s: string) => s.trim().toLowerCase().replace(/\.pdf$/i, ""); - const setCitationHighlightQuery = useUIStore((s) => s.setCitationHighlightQuery); const handleWorkspaceItemClick = () => { if (!title) return; - // Resolve by virtual path first (e.g. "pdfs/Syllabus.pdf") — AI may cite using paths from const items = workspaceState; const byPath = resolveItemByPath(items, title); const item = @@ -122,7 +125,6 @@ const CitationRenderer = memo( titleNorm(i.name) === titleNorm(title) ); if (!item) return; - // Set citation highlight: for PDFs with page, or when we have a quote to search if (quote?.trim() || (pageNumber != null && item.type === "pdf")) { setCitationHighlightQuery({ itemId: item.id, @@ -134,11 +136,10 @@ const CitationRenderer = memo( openWorkspaceItem(item.id); }; - // For path-like refs (e.g. pdfs/Syllabus.pdf), show basename in badge const displayTitle = title.includes("/") ? title.split("/").pop() ?? title : title; const badgeLabel = - displayTitle.slice(0, 20) + (displayTitle.length > 20 ? "…" : "") + - (pageNumber != null ? ` · p.${pageNumber}` : ""); + displayTitle.slice(0, 20) + (displayTitle.length > 20 ? "\u2026" : "") + + (pageNumber != null ? ` \u00b7 p.${pageNumber}` : ""); return ( @@ -163,9 +164,34 @@ const CitationRenderer = memo( ); CitationRenderer.displayName = "CitationRenderer"; -/** Props from assistant-ui when used as Text component, or optional when used directly (e.g. in Reasoning) */ +const MarkdownA = (props: AnchorHTMLAttributes & { node?: any }) => ( + +); + +const MarkdownCitation = (props: any) => ( + {props.children} +); + +const MarkdownOl = ({ children, node, ...props }: HTMLAttributes & { node?: any }) => ( +
    + {children} +
+); + +const MarkdownUl = ({ children, node, ...props }: HTMLAttributes & { node?: any }) => ( +
    + {children} +
+); + +const STREAMDOWN_COMPONENTS = { + a: MarkdownA, + citation: MarkdownCitation, + ol: MarkdownOl, + ul: MarkdownUl, +} as const; + type MarkdownTextProps = Partial & { - /** Use "reasoning" for smoother streaming in reasoning blocks (blurIn, longer duration) */ streamingVariant?: "default" | "reasoning"; }; @@ -173,8 +199,39 @@ const MarkdownTextImpl = (props: MarkdownTextProps) => { const streamingVariant = props.streamingVariant ?? "default"; const { text, status } = useMessagePartText(); - const threadId = useAuiState(({ threads }) => (threads as any)?.mainThreadId); - const messageId = useAuiState(({ message }) => (message as any)?.id); + const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); + const { state: workspaceState } = useWorkspaceState(workspaceId); + const navigateToItem = useNavigateToItem(); + const openWorkspaceItem = useUIStore((s) => s.openWorkspaceItem); + const setCitationHighlightQuery = useUIStore((s) => s.setCitationHighlightQuery); + + const citationUrlsRef = useRef>(new Map()); + + const { text: processedText, citationUrls: rawCitationUrls } = useMemo( + () => preprocessLatex(text), + [text] + ); + + const citationUrls = useMemo(() => { + const prev = citationUrlsRef.current; + if (prev.size === rawCitationUrls.size) { + let same = true; + for (const [k, v] of rawCitationUrls) { + if (prev.get(k) !== v) { same = false; break; } + } + if (same) return prev; + } + citationUrlsRef.current = rawCitationUrls; + return rawCitationUrls; + }, [rawCitationUrls]); + + const citationCtx = useMemo(() => ({ + workspaceState, + navigateToItem, + openWorkspaceItem, + setCitationHighlightQuery, + citationUrls, + }), [workspaceState, navigateToItem, openWorkspaceItem, setCitationHighlightQuery, citationUrls]); const animateConfig = streamingVariant === "reasoning" @@ -183,10 +240,6 @@ const MarkdownTextImpl = (props: MarkdownTextProps) => { const containerRef = useRef(null); - // Combine thread and message ID for unique key per message - const key = `${threadId || 'no-thread'}-${messageId || 'no-message'}`; - - // Set up wheel event handlers for all code blocks to prevent vertical scroll trapping useEffect(() => { const container = containerRef.current; if (!container) return; @@ -197,11 +250,9 @@ const MarkdownTextImpl = (props: MarkdownTextProps) => { if (!preElement) return; - // If user is primarily scrolling vertically (more vertical than horizontal movement) const isVerticalScroll = Math.abs(e.deltaY) > Math.abs(e.deltaX); if (isVerticalScroll) { - // Always let vertical scroll bubble up to parent - don't let code block trap it const scrollParent = preElement.closest('.aui-thread-viewport') as HTMLElement; if (scrollParent) { scrollParent.scrollTop += e.deltaY; @@ -216,9 +267,8 @@ const MarkdownTextImpl = (props: MarkdownTextProps) => { return () => { container.removeEventListener('wheel', handleWheel, { capture: true }); }; - }, [threadId, messageId]); + }, []); - // Ensure copied content keeps rich HTML but strips background/highlight styles const handleCopy = (event: ReactClipboardEvent) => { if (typeof window === "undefined") return; @@ -228,11 +278,9 @@ const MarkdownTextImpl = (props: MarkdownTextProps) => { const range = selection.getRangeAt(0); const fragment = range.cloneContents(); - // Wrap in a temporary container so we can serialize + sanitize const wrapper = document.createElement("div"); wrapper.appendChild(fragment); - // Strip background/highlight styles from copied HTML wrapper.querySelectorAll("*").forEach((el) => { el.style?.removeProperty("background"); el.style?.removeProperty("background-color"); @@ -253,44 +301,29 @@ const MarkdownTextImpl = (props: MarkdownTextProps) => { }; return ( -
- *:first-child]:mt-0 [&>*:last-child]:mb-0" - )} - linkSafety={{ enabled: false }} - plugins={{ code, mermaid, math }} - components={{ - a: (props: AnchorHTMLAttributes & { node?: any }) => ( - - ), - - citation: (props: any) => ( - {props.children} - ), - ol: ({ children, node, ...props }: HTMLAttributes & { node?: any }) => ( -
    - {children} -
- ), - ul: ({ children, node, ...props }: HTMLAttributes & { node?: any }) => ( -
    - {children} -
- ), - }} - mermaid={{ - config: { - theme: 'dark', - }, - }} - > - {preprocessLatex(text)} -
-
+ +
+ *:first-child]:mt-0 [&>*:last-child]:mb-0" + )} + linkSafety={{ enabled: false }} + plugins={{ code, mermaid, math }} + components={STREAMDOWN_COMPONENTS} + mermaid={{ + config: { + theme: 'dark', + }, + }} + > + {processedText} + +
+
); }; diff --git a/src/components/assistant-ui/reasoning.tsx b/src/components/assistant-ui/reasoning.tsx index 052a58dc..049aa67e 100644 --- a/src/components/assistant-ui/reasoning.tsx +++ b/src/components/assistant-ui/reasoning.tsx @@ -236,9 +236,11 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ }); const isLastMessage = useAuiState(({ thread, message }) => { - const messages = (thread as unknown as { messages?: Array<{ id?: string }> })?.messages ?? []; - const idx = messages.findIndex((m) => m.id === message.id); - return idx >= 0 && idx === messages.length - 1; + const messages = (thread as unknown as { messages?: Array<{ id?: string }> })?.messages; + if (!messages || messages.length === 0) return false; + const lastId = messages[messages.length - 1]?.id; + if (!lastId || !message.id) return false; + return lastId === message.id; }); // Subscribe to reasoning text length so we re-run scroll effect on each stream chunk diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index d78e2b97..f7db8ee1 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -69,7 +69,7 @@ import { ComposerAddAttachment, UserMessageAttachments, } from "@/components/assistant-ui/attachment"; -import { AssistantLoader } from "@/components/assistant-ui/assistant-loader"; +import { AssistantLoaderVisual } from "@/components/assistant-ui/assistant-loader"; import { File as FileComponent } from "@/components/assistant-ui/file"; import { isOfficeDocument } from "@/lib/uploads/office-document-validation"; import { Sources } from "@/components/assistant-ui/sources"; @@ -400,10 +400,12 @@ const ComposerHoverWrapper: FC = ({ items }) => { null, ); const isThreadEmpty = useAuiState(({ thread }) => thread?.isEmpty ?? true); - const composerText = useAuiState( - (s) => (s as { composer?: { text?: string } })?.composer?.text ?? "", + const hasComposerText = useAuiState( + (s) => { + const text = (s as { composer?: { text?: string } })?.composer?.text; + return Boolean(text?.trim()); + }, ); - const hasComposerText = Boolean(composerText?.trim()); const handleDirectFill = useCallback( (fill: string) => { @@ -1142,6 +1144,19 @@ const MessageError: FC = () => { ); }; +const ASSISTANT_MESSAGE_PARTS = { + Text: MarkdownText, + File: FileComponent, + Source: Sources, + Image, + Reasoning: Reasoning, + ReasoningGroup: ReasoningGroup, + ToolGroup, + tools: { + Fallback: ToolFallback, + }, +} as const; + const AssistantMessage: FC = () => { return ( @@ -1150,21 +1165,13 @@ const AssistantMessage: FC = () => { data-role="assistant" >
- - + + (message as any).status?.type === "running" && + (!(message as any).content || (Array.isArray((message as any).content) && (message as any).content.length === 0)) + }> + + +
@@ -1243,6 +1250,11 @@ const UserMessageText: FC = () => { return
{text}
; }; +const USER_MESSAGE_PARTS = { + Text: UserMessageText, + File: FileComponent, +} as const; + const UserMessage: FC = () => { const [expanded, setExpanded] = useState(false); const message = useMessage(); @@ -1282,12 +1294,7 @@ const UserMessage: FC = () => {
- + {showExpand && (